mirror of
https://github.com/frappe/lms.git
synced 2026-05-06 07:29:32 +03:00
Compare commits
848 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6775e7279 | |||
| 3e7555264f | |||
| ab155ae609 | |||
| 2c60521894 | |||
| 4fd3e2549a | |||
| 6d3f7ef3c1 | |||
| 10a9a5230e | |||
| bfda88dfd2 | |||
| 4be34848a4 | |||
| c51eae5665 | |||
| 6c06a86af4 | |||
| 8be53d9050 | |||
| 015f903d68 | |||
| dc06ad6b22 | |||
| 5689fbb455 | |||
| 62631daafb | |||
| a9a322c9af | |||
| 933bc58264 | |||
| 8896a79c09 | |||
| 3170c066dc | |||
| 7360780022 | |||
| 7fe398fc66 | |||
| 5970540a99 | |||
| 486e2b4a37 | |||
| d8891b8a7d | |||
| 98c5318b66 | |||
| a353635bb9 | |||
| 9eeb948f04 | |||
| 383850aeb8 | |||
| fb0499acc4 | |||
| ff978f5c29 | |||
| fa1b583968 | |||
| b50d584a5b | |||
| 5271709094 | |||
| b66b603af2 | |||
| 53c77f9070 | |||
| 49ed082831 | |||
| 05d21cf817 | |||
| 39473f0037 | |||
| 0e8b232ef1 | |||
| aeb2724e82 | |||
| b429fb2e47 | |||
| 14a1c2ac07 | |||
| 36b0594960 | |||
| cc3cb7ac8d | |||
| 8bf896f766 | |||
| 9e61982dcb | |||
| 59e40d5a83 | |||
| 412bdeb085 | |||
| 4ef0bd30d9 | |||
| 87e1fa8d6c | |||
| 984ea27d59 | |||
| 9d2c7a7ce0 | |||
| 3b2768cdcc | |||
| 9ef5f01a80 | |||
| fe492d1134 | |||
| 3a15e132c2 | |||
| 2fb20e2db1 | |||
| 1fd9b4e626 | |||
| 11668235c2 | |||
| d349af940a | |||
| 7eadb6b683 | |||
| 904b6c3462 | |||
| 733ab34007 | |||
| 6215817954 | |||
| 1760b01914 | |||
| d218a4773a | |||
| 6fbe32cacd | |||
| c10dd9f5b6 | |||
| 7eb1237aa0 | |||
| 570e8eaf34 | |||
| 297ffa5171 | |||
| 355c752ec1 | |||
| f1031c49a9 | |||
| 043c7902a3 | |||
| 338f46ac17 | |||
| 9987ea1db6 | |||
| d507479bda | |||
| afbdb46fe8 | |||
| 1308101fe6 | |||
| c2d6e160d9 | |||
| 28c88ec519 | |||
| f77eeafb9e | |||
| 92b8343db3 | |||
| ac44cdc303 | |||
| 75dcea4c7e | |||
| ee03d75b5f | |||
| 31f372629a | |||
| 1b30f476e8 | |||
| e4738e9a50 | |||
| 893f9d34fd | |||
| 2c17a03d36 | |||
| 74cbe1bb47 | |||
| 88c468068b | |||
| 6b5e269564 | |||
| 97d9460157 | |||
| 0d614a5919 | |||
| 3f6afbf1ff | |||
| 6d0996ebfd | |||
| 14e085c826 | |||
| 53bf2f1fe8 | |||
| b06e199432 | |||
| 06e1ec38ad | |||
| 3063236bfb | |||
| e3a2eb382d | |||
| 566ef576f5 | |||
| 80ad2da206 | |||
| 5d720031b6 | |||
| ac719b9e8e | |||
| db9ebb1d86 | |||
| 37f6d62101 | |||
| f7a0fc8b5f | |||
| 0fbe015607 | |||
| 61909c8498 | |||
| f297f2630b | |||
| 9425f2aa0e | |||
| 535bb60d69 | |||
| 5175050ed6 | |||
| 66486a5a1c | |||
| 2d4bf49ab9 | |||
| 50a091a7b0 | |||
| 854ebee2e6 | |||
| c829ab65d9 | |||
| 1e0e10ca59 | |||
| a8cdc76278 | |||
| c310460727 | |||
| 3e98d962aa | |||
| ad90b89d25 | |||
| 92eac9634a | |||
| 96015246bd | |||
| 0883aedc1d | |||
| c97a5e813c | |||
| 2abc243b88 | |||
| 62b18e6ffa | |||
| 36d813b90f | |||
| 5ce8e8c4ff | |||
| 3b88892905 | |||
| fe1aa3dd40 | |||
| ec5e197716 | |||
| 244b5e445c | |||
| 0f927071c0 | |||
| 376de99ef7 | |||
| 7a649957dd | |||
| 0968c90717 | |||
| e22eca9888 | |||
| 5890885475 | |||
| 4a012a99a4 | |||
| e2c0355821 | |||
| bcf27b7150 | |||
| 078f18d99c | |||
| 19258e263d | |||
| 7a52a2bf46 | |||
| a03be5ab4d | |||
| de13c5ddfb | |||
| 02564b2e77 | |||
| 201e0b96a3 | |||
| e7ccf0a711 | |||
| c59be28a26 | |||
| a0ede1dd2a | |||
| 0aeada4549 | |||
| 6d988eb2b4 | |||
| b58d04c7dc | |||
| e2479cd787 | |||
| ca30ab8a5e | |||
| da87845a4c | |||
| e7c2ec6965 | |||
| a7bcc53e0a | |||
| 6a5978fed6 | |||
| 8ff339b7ed | |||
| 4ace8b2ec0 | |||
| 40c917f255 | |||
| bf024af8aa | |||
| 675caa380f | |||
| ad092a71d5 | |||
| 971fe8fe64 | |||
| fa63a1d5e5 | |||
| ece642796a | |||
| ce8f1a5b77 | |||
| 973630059f | |||
| 0f7e9d2d95 | |||
| a7f494f4d8 | |||
| eb37bd1106 | |||
| 02c1f4c19e | |||
| 00040061f6 | |||
| cf6292c2c6 | |||
| 0cf9984b6b | |||
| e793afd063 | |||
| 6ce655c5b6 | |||
| dd7eb9a9c8 | |||
| bd8baa6671 | |||
| d387c7b493 | |||
| c7a991e1b0 | |||
| f7e8707220 | |||
| 27cbc265e2 | |||
| 949ea333eb | |||
| d32a80ef85 | |||
| d0ee3fe2d0 | |||
| d4e3676032 | |||
| 0265c9dc4c | |||
| 4c81eef80d | |||
| 57c7a8d85c | |||
| 775d5ecf3a | |||
| 60fcf0472e | |||
| a09599ec8a | |||
| fb5d79fc77 | |||
| 8ad5288226 | |||
| 65b18641b5 | |||
| 78494b9963 | |||
| 1d83402163 | |||
| 64bdd85b03 | |||
| 0f1b6f3eeb | |||
| 566711df22 | |||
| c9c6aef466 | |||
| ad650c73c1 | |||
| af5bc2b071 | |||
| b5fcfb62de | |||
| 7dccef6b10 | |||
| e1d343528d | |||
| 6eeb688f06 | |||
| 86e3794d00 | |||
| 45e98b9ddc | |||
| aad875f72c | |||
| 1698bf0bca | |||
| ea94813d94 | |||
| 142b893f2c | |||
| f88dd84ed3 | |||
| 50660f6720 | |||
| 958128060c | |||
| b2b1d2bb00 | |||
| a3069bd760 | |||
| f39ee39452 | |||
| a43e90e2d0 | |||
| 2aa3765ed3 | |||
| 45dae0b9d3 | |||
| b1789cdcba | |||
| e21f0e3a7f | |||
| ea9975db2c | |||
| b1c8e01bf5 | |||
| 1d19389fc0 | |||
| dbd3e17b26 | |||
| b17c7ca2db | |||
| e17af04c9a | |||
| 638a9abf88 | |||
| 89f9cbd30d | |||
| 204fb669c0 | |||
| 7a24a83d9e | |||
| edc6007fb6 | |||
| 27a36540d4 | |||
| 6dd1274150 | |||
| 6a80c2ab38 | |||
| 8633444b91 | |||
| cb014a9507 | |||
| c241abb820 | |||
| a497a2d838 | |||
| 5e78848d38 | |||
| adf897cc08 | |||
| 5053b4e45f | |||
| 3151854bfd | |||
| db5868f69a | |||
| 9b6f7635bc | |||
| 55ff59095c | |||
| 3dce1c0930 | |||
| fd33aa8b70 | |||
| 05170da762 | |||
| 64f0c693d2 | |||
| aac1e2d01f | |||
| 5472c4d387 | |||
| 39a31a0baf | |||
| cffc740ed3 | |||
| 114f3aae6d | |||
| fea7f8f9ae | |||
| 830f513a06 | |||
| e76ff45241 | |||
| 66f19d06b0 | |||
| 987b655976 | |||
| 8619712d20 | |||
| bc6ca205d5 | |||
| 48d17e88d9 | |||
| 1e9f1b7661 | |||
| faa6fbf68e | |||
| 789b1016fb | |||
| 8fb491ac18 | |||
| 3eea872137 | |||
| e4d98019e7 | |||
| 7847f681e9 | |||
| 26d7971d99 | |||
| e25ef7e07b | |||
| 85fafe7d56 | |||
| fdced5c204 | |||
| b21fe69123 | |||
| 4572d03470 | |||
| d348fd9f99 | |||
| 4b6e91e81f | |||
| 9126e7cd27 | |||
| 713ee5287f | |||
| 79109f1265 | |||
| 20f16849bf | |||
| 99968f5961 | |||
| 0741fbf583 | |||
| 784ed37de0 | |||
| 1c29b4966c | |||
| a06ea92c8e | |||
| 60a47889d2 | |||
| d758039b2c | |||
| 4b507f0706 | |||
| 5986838056 | |||
| e02c99bd16 | |||
| a3f580a9fb | |||
| 43fb0f92df | |||
| dd4fbfa8a2 | |||
| cf2e57ec40 | |||
| daf2d28f3a | |||
| d5e48f9502 | |||
| 3318a1c599 | |||
| 3fa2320fe3 | |||
| c0c8fb5bf3 | |||
| acde5ad1d1 | |||
| 0a9b18d04d | |||
| 3013372711 | |||
| 76979e292d | |||
| 115d52776f | |||
| f1b392ac9b | |||
| 00198464e9 | |||
| 1b84f00673 | |||
| 3d9850dc73 | |||
| dfcf295493 | |||
| b987bf7f20 | |||
| e1d160a898 | |||
| a3fb63cd08 | |||
| 1faa697b6c | |||
| 01094cd10a | |||
| 4141022431 | |||
| 69d0efbfa7 | |||
| 66cc7d392e | |||
| 8048cb47c5 | |||
| 09c668f7ed | |||
| 984a63c46a | |||
| 80c978d265 | |||
| cde6828c1f | |||
| 51c1b816a1 | |||
| 356ce7478c | |||
| 2210ef7af7 | |||
| 0884e1315b | |||
| 58b26b6e32 | |||
| 2631681c1d | |||
| 82d6284b06 | |||
| 46d13d65c1 | |||
| 9da6cff8a5 | |||
| dc724831c3 | |||
| 776730447a | |||
| 82c6fdf475 | |||
| ca2b175e1c | |||
| ec7e250f96 | |||
| 126570fcca | |||
| a7bbb7f150 | |||
| 4345ff18bd | |||
| 26409b0336 | |||
| 7653b64353 | |||
| 32dd5832b8 | |||
| 59072c3580 | |||
| d121cd3526 | |||
| cfbac5f2c4 | |||
| 371c72b96c | |||
| 55a02004bd | |||
| b3119f5295 | |||
| f57b64531c | |||
| 94a3afdc9b | |||
| b65102e6c8 | |||
| 0f1b48b3e3 | |||
| 0a62da02fe | |||
| ae54e39b69 | |||
| ea565d7334 | |||
| d11f625ecb | |||
| ab6761a6ce | |||
| 081bc0eaa6 | |||
| 30748b7287 | |||
| ad928eb2a6 | |||
| bdb281dfa3 | |||
| 8d26800a5d | |||
| f0d85b391c | |||
| ac205b6944 | |||
| c6979a2f61 | |||
| 0bbf4a9925 | |||
| 16b047bb73 | |||
| f674fdcc0d | |||
| 05b40ae47e | |||
| ce5413f622 | |||
| fbf4971f52 | |||
| a2e2de2ec3 | |||
| 9937851146 | |||
| a0bdb84d3f | |||
| 21fca61fab | |||
| 1157e6a007 | |||
| 6d9db8ef46 | |||
| 8c23510622 | |||
| 660945b8fa | |||
| 6550e1c926 | |||
| 49bc5750a1 | |||
| 163e4b8b1e | |||
| 52fb5e2ad8 | |||
| 2596b85eb2 | |||
| 1210a6aa87 | |||
| d3a27e8bc9 | |||
| 6cabb4eed7 | |||
| 38e6320d8f | |||
| 8d41a3d688 | |||
| 7bf6311a90 | |||
| 41660a1dfe | |||
| 46b467847e | |||
| 810635d964 | |||
| b0a96641ef | |||
| f2846da4ad | |||
| e150225226 | |||
| 4580ab0181 | |||
| f8c10d1807 | |||
| f783c6a62f | |||
| 1bc610bd76 | |||
| f49bb98b92 | |||
| 819318de37 | |||
| 3ebff2143a | |||
| 80deed2be7 | |||
| 8de3996d36 | |||
| b56fd01f39 | |||
| 820ea7e2a4 | |||
| 3de5fb0622 | |||
| a5f112ff16 | |||
| 32b3fceb3b | |||
| 1d7c88674d | |||
| ce158692d4 | |||
| 9845498f76 | |||
| 7b8250056b | |||
| 80a217e646 | |||
| 0877e32e1b | |||
| 316e739dd6 | |||
| 43efebe3a7 | |||
| ca849da815 | |||
| 9470cc192c | |||
| 631008832c | |||
| 7fc066679d | |||
| 73ee1b2f09 | |||
| 4c98282335 | |||
| 7e3c5beaea | |||
| 59d27e06a0 | |||
| 2caddafd0b | |||
| cbfc10c08b | |||
| ae8ffd4cbd | |||
| e768d5d55c | |||
| 829245f373 | |||
| c901a15969 | |||
| 017798ce89 | |||
| d16874da7c | |||
| d8c9204b61 | |||
| 1ab51b423f | |||
| ce110e8758 | |||
| e257a3f757 | |||
| 49a6305dec | |||
| 1b30df0c41 | |||
| edb2369967 | |||
| 4f93908ab1 | |||
| dc95c63c62 | |||
| 28a0cb5b12 | |||
| a4cbcc2f32 | |||
| 0add20e637 | |||
| 50a8550a0c | |||
| dbd6ac10e9 | |||
| 1252234eed | |||
| 5ee7a6efb6 | |||
| a4bfe3752b | |||
| dd034ec7ae | |||
| c93b189ede | |||
| 2819531d57 | |||
| dc1ce8e55e | |||
| 2e0266a265 | |||
| bcd7aca2ff | |||
| 8c66558b63 | |||
| a263bcd8c3 | |||
| 97a873c6b0 | |||
| fc43259dcb | |||
| 73116446e2 | |||
| f0d3439071 | |||
| 31a1aa7cac | |||
| 34cd751114 | |||
| 196d4a835f | |||
| d82517f402 | |||
| bcda74a455 | |||
| 1f65439efd | |||
| f95b62a6a6 | |||
| 0dc0794add | |||
| 552b5845ea | |||
| b05739257d | |||
| 61215cf0ad | |||
| bc84e46e09 | |||
| 645581e202 | |||
| 4dddb9f2e1 | |||
| 6e93f952ab | |||
| 93ffcdb8f9 | |||
| 040d74c20a | |||
| 5825bcf9b3 | |||
| 114d183524 | |||
| de356c4e64 | |||
| fedd5e6a14 | |||
| 29217ab2bb | |||
| 95f37f7120 | |||
| 87a7b93334 | |||
| 0d430ad86c | |||
| 308a108e60 | |||
| 17401a330d | |||
| f9d7463710 | |||
| 097c53c7b1 | |||
| 9ae2e5babb | |||
| 1f29f7a282 | |||
| 1d1fcd5f6d | |||
| b6f8f87923 | |||
| 466b248c30 | |||
| 7c6747aeb0 | |||
| 836a6d1203 | |||
| a3bd9d2706 | |||
| 2cdd45da96 | |||
| 0367c1db72 | |||
| 5f17802ab8 | |||
| b3e90c7f2f | |||
| 6c2978306c | |||
| b951e1567c | |||
| 97cdb57406 | |||
| 651300b043 | |||
| b537e2789d | |||
| 3cd766bc74 | |||
| 30ee06c0ab | |||
| 74a1f1dc77 | |||
| 7a6f6d2c7c | |||
| de8868ec68 | |||
| 7105d6271f | |||
| b04a3de201 | |||
| 0107032ee3 | |||
| 34e07b0083 | |||
| 6c16516e89 | |||
| 7bc6dff6ea | |||
| 1c0be8a2ec | |||
| 4a9f1197fb | |||
| ad4e7496cb | |||
| d321b81a64 | |||
| 3691e3f240 | |||
| 62e2fe56d0 | |||
| 4a6a98c533 | |||
| 4e5d44c464 | |||
| b2e8fbd84d | |||
| 41dde09a6a | |||
| 7bbfb85b0e | |||
| c99d05cf6c | |||
| f856aaaacd | |||
| d029d4e371 | |||
| 04f3744624 | |||
| 4e1d00afff | |||
| ccfa281490 | |||
| 8979b5ef0c | |||
| 765bd8bfab | |||
| 4e7f371c49 | |||
| 5ca5624f99 | |||
| 6e5066022b | |||
| b16d8cbd6d | |||
| 030b3095a9 | |||
| d9febdaf82 | |||
| 3143fc8b2a | |||
| d8396d13b4 | |||
| 6a474b25ef | |||
| c8494f3246 | |||
| 1d66f9695a | |||
| 053a9cd3a9 | |||
| b7546fd2f4 | |||
| 61fc0a9ce7 | |||
| 251abad821 | |||
| f581e7894d | |||
| c16b81bfa2 | |||
| 4fcb19010c | |||
| 7f1e2a18ea | |||
| 7f4bb9e05d | |||
| 58d9dd3c26 | |||
| 609ed3cb09 | |||
| 67dd8ac151 | |||
| fc82ec8070 | |||
| f218230ad7 | |||
| f8380226ee | |||
| b087faeb90 | |||
| a333b0b754 | |||
| 2614fbc94c | |||
| 66c70dd233 | |||
| 8d1c0a7bd1 | |||
| eae74dacae | |||
| ca0fed9f17 | |||
| cfc5d94711 | |||
| 924a11e4f4 | |||
| 3be3124951 | |||
| c846e36032 | |||
| cf9e5f861b | |||
| 219139e45b | |||
| eab43a66cf | |||
| faa18d6a88 | |||
| 470e446ae6 | |||
| af3f1a5fc3 | |||
| 3b925246ee | |||
| e2d7b409bd | |||
| 731e242974 | |||
| 9614f8eb9d | |||
| a98e8025c4 | |||
| 397b7ee032 | |||
| 22b041d252 | |||
| 5f20d8ad63 | |||
| d3879a9d11 | |||
| 0d0b6dcd36 | |||
| 588f796069 | |||
| 440c51f1ca | |||
| b99b2bd123 | |||
| e502ff3491 | |||
| 1ae0f87f9c | |||
| 5a3e72eaaf | |||
| 41c8384ef5 | |||
| 12a5641cb4 | |||
| e8cd305171 | |||
| 880b799df5 | |||
| 4c6dc25589 | |||
| 10a301eebc | |||
| e6e50c96e4 | |||
| fab2ee8420 | |||
| 58eb3ccacb | |||
| aacd5ab7a1 | |||
| 38b0b9ceb1 | |||
| 9fb2a169e8 | |||
| 5977be4a14 | |||
| 5b5c53bebc | |||
| 9b38e62eaf | |||
| 7a490b19bd | |||
| 4d34e9e702 | |||
| 6fbd504e39 | |||
| b0b79f1d19 | |||
| 3d7a3ecfc5 | |||
| 9b7d763d52 | |||
| aae8624269 | |||
| 7fd16915c0 | |||
| eacb9fe356 | |||
| a7305b679e | |||
| d48405d440 | |||
| 35157c0b58 | |||
| ee7aacd776 | |||
| 2c08b94c3a | |||
| d34cfbc327 | |||
| d9933e6933 | |||
| 7dbb215a18 | |||
| 132f8be21c | |||
| 5fac2198cd | |||
| f94b4d1205 | |||
| fc75f92e89 | |||
| 998049872d | |||
| f9ed0bab5e | |||
| b87497411b | |||
| b6be206630 | |||
| f7a4350fe8 | |||
| 802a104a49 | |||
| 35aea2dd77 | |||
| 6ef7dd75e9 | |||
| 115ccdb26a | |||
| 6f888bcf4a | |||
| d55de747f5 | |||
| ba45c57cc6 | |||
| 8632f81237 | |||
| f7c2ec7fa6 | |||
| 6617c1ef54 | |||
| 229c537731 | |||
| d0060d828f | |||
| 98f9778464 | |||
| 1f6a0194f7 | |||
| c7915e2c3d | |||
| 41f7979eb4 | |||
| 343ed8d22f | |||
| 1c038d3334 | |||
| 1c3cdec563 | |||
| 6c37f0f4fe | |||
| f9967bff2e | |||
| 96a9e34487 | |||
| 2eb1574131 | |||
| 583871912b | |||
| f4483d7973 | |||
| 21f49690bd | |||
| 18ebac2130 | |||
| 1deee7e396 | |||
| 0a72f0a9a9 | |||
| 1932338660 | |||
| a481bcd974 | |||
| d86fd0f6f6 | |||
| bca70e0842 | |||
| cf659f93d8 | |||
| 8bfc2a5297 | |||
| 783f0ed750 | |||
| 90e4097fa3 | |||
| 2aac558d4a | |||
| 9d3714eb90 | |||
| 46f5808fdb | |||
| 8dbc85d03d | |||
| f4be59f958 | |||
| 4d38f0637c | |||
| feb7758830 | |||
| 915be8dbdc | |||
| 3a6d0998c4 | |||
| 048bc7e421 | |||
| 3b0643da47 | |||
| f873b396b6 | |||
| 5ec67115ba | |||
| 2a21714ed1 | |||
| 1913529bf0 | |||
| 419f47d36e | |||
| fba2b1ea9b | |||
| 9f53e2c8da | |||
| 13f2a79ebb | |||
| 800f624d1d | |||
| 91e7b18506 | |||
| cc3a46b1ff | |||
| a4aed7b61f | |||
| 1aee97c64f | |||
| d33980a8c6 | |||
| c589def0de | |||
| c70ef1e2e5 | |||
| 60b6a73bd7 | |||
| b755f3b29b | |||
| be4eeeb9d7 | |||
| 31beae63fd | |||
| 16a530fb50 | |||
| b8c5b7f479 | |||
| f64475b793 | |||
| 8e6de04e23 | |||
| 25f6440b1b | |||
| c1bdfe33f0 | |||
| d7de538345 | |||
| d2950bc0b5 | |||
| 6333f58f56 | |||
| bc187aabfe | |||
| f825887181 | |||
| 2bdc35055c | |||
| b6602f9e4b | |||
| ab366837a2 | |||
| c0a7a9b753 | |||
| c951732eb4 | |||
| 1b638d118d | |||
| 1173ac6504 | |||
| 9447903d5b | |||
| fff9769791 | |||
| d5c5faf0ca | |||
| 85d793ee64 | |||
| cf9c9fb5d3 | |||
| b7144727e9 | |||
| 60cd84972c | |||
| 5854d2514f | |||
| 226893a8b2 | |||
| b182d5ea16 | |||
| 6bc28eafbf | |||
| 1983190da3 | |||
| 656c0cf012 | |||
| 60ddc1f8b2 | |||
| c3604ab74a | |||
| eac0428dc6 | |||
| 53cd427a75 | |||
| 96cf6ddbf9 | |||
| 66c2ec013c | |||
| e3d5bf0220 | |||
| 3281358282 | |||
| 7a47591967 | |||
| 6931ca27c3 | |||
| d00d2de1cc | |||
| b1be568991 | |||
| 28be3891d2 | |||
| 27d2297e2b | |||
| 7212ddd5c5 | |||
| f4e9ac5bf1 | |||
| 18e499e6de | |||
| 8fec484d66 | |||
| 514d52f895 | |||
| 8719fa6696 | |||
| bcf781c37b | |||
| d8a8e689d0 | |||
| a844b95de3 | |||
| ece885f973 | |||
| 66dd30604b | |||
| d0f0f4905c | |||
| c9cb6702b6 | |||
| 1ddb980242 | |||
| 94b626a4d2 | |||
| d2a011462d | |||
| 4c34926af0 | |||
| ce35cd1009 | |||
| 56d072bd06 | |||
| 5d336ef669 | |||
| b47c59eac1 | |||
| 87285db361 | |||
| 84312e498c | |||
| bd763d9462 | |||
| a00e66f786 | |||
| 78c7b52088 | |||
| c3a5bee993 | |||
| c2b5b7c3e2 | |||
| 3992f00353 | |||
| 97d853e0d3 | |||
| f786cec75f | |||
| 07cd08b55e | |||
| ca42faf14a | |||
| 87f5b68279 | |||
| 9c38444c4b | |||
| a5f9adc875 | |||
| 6b31edb687 | |||
| 6a64048bb6 | |||
| 6cf069ee6a | |||
| 74de43c3d6 | |||
| 3b74bba6ab | |||
| 9f81bf695c | |||
| 8689788523 | |||
| 3caf743f29 | |||
| ad28218893 | |||
| 760811f172 | |||
| 68e7684da3 | |||
| ca2cc7bbda | |||
| 741cc4ccc7 | |||
| 0c1f1fada4 | |||
| 5a91c73a91 | |||
| 9da1bfeea1 | |||
| d4b603a4dd | |||
| a1a302f222 | |||
| 7d2b98f674 | |||
| 2f5b0a3bf8 | |||
| 1efd5ebad5 | |||
| acb5e5e1c9 | |||
| 0f24fd6edc | |||
| 99c448e0e5 | |||
| cabb499a43 | |||
| 6933105261 | |||
| bf36890bd3 | |||
| 05b6b97b2a | |||
| 6a8aca39a0 | |||
| 7434a324fb | |||
| fd934e1e82 | |||
| dd94d12e3a | |||
| fe85dd867d | |||
| 12f2047910 | |||
| cb6931bd88 | |||
| c76f9141fc | |||
| ddf70ce3d4 | |||
| 7beb82e804 | |||
| 8d9b8951bf | |||
| b20d045c8e | |||
| 736ce8eb3f | |||
| 3409049559 | |||
| 5212122946 |
@@ -7,6 +7,8 @@ on:
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
services:
|
||||
redis-cache:
|
||||
image: redis:alpine
|
||||
@@ -30,13 +32,13 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: setup python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.10'
|
||||
python-version: '3.14'
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '24'
|
||||
check-latest: true
|
||||
- name: setup cache for bench
|
||||
uses: actions/cache@v4
|
||||
@@ -69,6 +71,9 @@ jobs:
|
||||
- name: setup requirements
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: bench setup requirements --dev
|
||||
- name: block endpoints
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: bench --site frappe.local set-config block_endpoints 1
|
||||
- name: allow tests
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: bench --site frappe.local set-config allow_tests true
|
||||
@@ -77,4 +82,27 @@ jobs:
|
||||
run: bench --site frappe.local build
|
||||
- name: run tests
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: bench --site frappe.local run-tests --app lms
|
||||
run: bench --site frappe.local run-tests --app lms --coverage
|
||||
- name: Upload coverage data
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
path: /home/runner/frappe-bench/sites/coverage.xml
|
||||
|
||||
coverage:
|
||||
name: Coverage Wrap Up
|
||||
needs: tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Upload coverage data
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
name: Server
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: true
|
||||
verbose: true
|
||||
@@ -22,9 +22,14 @@ jobs:
|
||||
ref: ${{ matrix.branch }}
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Run script to update POT file
|
||||
run: |
|
||||
|
||||
@@ -16,9 +16,9 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 200
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Check commit titles
|
||||
@@ -35,9 +35,9 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.10'
|
||||
python-version: '3.14'
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
|
||||
@@ -15,9 +15,9 @@ jobs:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
- name: Setup dependencies
|
||||
run: |
|
||||
npm install @semantic-release/git @semantic-release/exec --no-save
|
||||
|
||||
@@ -36,9 +36,9 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
python-version: '3.14'
|
||||
|
||||
- name: Check for valid Python & Merge Conflicts
|
||||
run: |
|
||||
@@ -48,9 +48,9 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Add to Hosts
|
||||
|
||||
@@ -12,4 +12,5 @@ node_modules
|
||||
package-lock.json
|
||||
lms/public/frontend
|
||||
lms/www/lms.html
|
||||
lms/www/_lms.html
|
||||
frappe-ui
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"branches": ["develop"],
|
||||
"branches": ["main"],
|
||||
"plugins": [
|
||||
"@semantic-release/commit-analyzer", {
|
||||
"preset": "angular"
|
||||
|
||||
@@ -27,6 +27,10 @@ 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
|
||||
cy.get("[data-dismissable-layer]")
|
||||
.find("span")
|
||||
@@ -48,6 +52,7 @@ describe("Batch Creation", () => {
|
||||
|
||||
// Create a batch
|
||||
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");
|
||||
@@ -155,6 +160,7 @@ describe("Batch Creation", () => {
|
||||
cy.get("button:visible").contains("Manage Batch").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);
|
||||
|
||||
@@ -9,8 +9,8 @@ describe("Course Creation", () => {
|
||||
|
||||
// Create a course
|
||||
cy.get("button").contains("Create").click();
|
||||
cy.get("span").contains("New Course").click();
|
||||
cy.wait(500);
|
||||
cy.url().should("include", "/courses/new/edit");
|
||||
|
||||
cy.get("label").contains("Title").type("Test Course");
|
||||
cy.get("label")
|
||||
@@ -34,6 +34,29 @@ describe("Course Creation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/* Instructor */
|
||||
cy.get("label")
|
||||
.contains("Instructors")
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get("input").click().type("frappe");
|
||||
cy.wait(500);
|
||||
cy.get("input")
|
||||
.invoke("attr", "aria-controls")
|
||||
.as("instructor_list_id");
|
||||
});
|
||||
cy.get("@instructor_list_id").then((instructor_list_id) => {
|
||||
cy.get(`[id^=${instructor_list_id}`)
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.get("[id^=headlessui-combobox-option-").first().click();
|
||||
});
|
||||
});
|
||||
|
||||
cy.button("Create").last().click();
|
||||
|
||||
// Edit Course Details
|
||||
cy.wait(500);
|
||||
cy.get("label")
|
||||
.contains("Preview Video")
|
||||
.type("https://www.youtube.com/embed/-LPmw2Znl2c");
|
||||
@@ -49,31 +72,13 @@ describe("Course Creation", () => {
|
||||
.first()
|
||||
.click();
|
||||
|
||||
/* Instructor */
|
||||
cy.get("label")
|
||||
.contains("Instructors")
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get("input").click().type("frappe");
|
||||
cy.get("input")
|
||||
.invoke("attr", "aria-controls")
|
||||
.as("instructor_list_id");
|
||||
});
|
||||
cy.get("@instructor_list_id").then((instructor_list_id) => {
|
||||
cy.get(`[id^=${instructor_list_id}`)
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.get("[id^=headlessui-combobox-option-").first().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.button("Add Chapter").click();
|
||||
cy.button("Add").click();
|
||||
|
||||
cy.wait(1000);
|
||||
cy.get("[data-dismissable-layer]")
|
||||
|
||||
+1
-1
Submodule frappe-ui updated: 310089f4a4...78025c6794
Vendored
+3
-1
@@ -6,5 +6,7 @@
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
|
||||
const LucideGithub: typeof import('~icons/lucide/github').default
|
||||
const LucideLinkedin: typeof import('~icons/lucide/linkedin').default
|
||||
const LucideTwitter: typeof import('~icons/lucide/twitter').default
|
||||
}
|
||||
|
||||
Vendored
+16
-9
@@ -8,11 +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']
|
||||
AppHeader: typeof import('./src/components/AppHeader.vue')['default']
|
||||
Apps: typeof import('./src/components/Apps.vue')['default']
|
||||
AppSidebar: typeof import('./src/components/AppSidebar.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']
|
||||
@@ -42,12 +42,18 @@ declare module 'vue' {
|
||||
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
|
||||
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
|
||||
ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default']
|
||||
CommandPalette: typeof import('./src/components/CommandPalette/CommandPalette.vue')['default']
|
||||
CommandPaletteGroup: typeof import('./src/components/CommandPalette/CommandPaletteGroup.vue')['default']
|
||||
Configuration: typeof import('./src/components/Sidebar/Configuration.vue')['default']
|
||||
ContactUsEmail: typeof import('./src/components/ContactUsEmail.vue')['default']
|
||||
CouponDetails: typeof import('./src/components/Settings/Coupons/CouponDetails.vue')['default']
|
||||
CouponItems: typeof import('./src/components/Settings/Coupons/CouponItems.vue')['default']
|
||||
CouponList: typeof import('./src/components/Settings/Coupons/CouponList.vue')['default']
|
||||
Coupons: typeof import('./src/components/Settings/Coupons/Coupons.vue')['default']
|
||||
CourseCard: typeof import('./src/components/CourseCard.vue')['default']
|
||||
CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default']
|
||||
CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default']
|
||||
CourseOutline: typeof import('./src/components/CourseOutline.vue')['default']
|
||||
CourseProgressSummary: typeof import('./src/components/Modals/CourseProgressSummary.vue')['default']
|
||||
CourseReviews: typeof import('./src/components/CourseReviews.vue')['default']
|
||||
CreateOutline: typeof import('./src/components/CreateOutline.vue')['default']
|
||||
DateRange: typeof import('./src/components/Common/DateRange.vue')['default']
|
||||
@@ -73,7 +79,6 @@ declare module 'vue' {
|
||||
InviteIcon: typeof import('./src/components/Icons/InviteIcon.vue')['default']
|
||||
JobApplicationModal: typeof import('./src/components/Modals/JobApplicationModal.vue')['default']
|
||||
JobCard: typeof import('./src/components/JobCard.vue')['default']
|
||||
LayoutHeader: typeof import('./src/components/LayoutHeader.vue')['default']
|
||||
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']
|
||||
@@ -88,6 +93,7 @@ declare module 'vue' {
|
||||
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
|
||||
Notes: typeof import('./src/components/Notes/Notes.vue')['default']
|
||||
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
|
||||
NumberChartGraph: typeof import('./src/components/NumberChartGraph.vue')['default']
|
||||
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
|
||||
PaymentGatewayDetails: typeof import('./src/components/Settings/PaymentGatewayDetails.vue')['default']
|
||||
PaymentGateways: typeof import('./src/components/Settings/PaymentGateways.vue')['default']
|
||||
@@ -105,18 +111,19 @@ declare module 'vue' {
|
||||
SettingDetails: typeof import('./src/components/Settings/SettingDetails.vue')['default']
|
||||
SettingFields: typeof import('./src/components/Settings/SettingFields.vue')['default']
|
||||
Settings: typeof import('./src/components/Settings/Settings.vue')['default']
|
||||
SidebarLink: typeof import('./src/components/SidebarLink.vue')['default']
|
||||
SidebarLink: typeof import('./src/components/Sidebar/SidebarLink.vue')['default']
|
||||
StudentHeatmap: typeof import('./src/components/StudentHeatmap.vue')['default']
|
||||
StudentModal: typeof import('./src/components/Modals/StudentModal.vue')['default']
|
||||
Tags: typeof import('./src/components/Tags.vue')['default']
|
||||
TransactionDetails: typeof import('./src/components/Settings/TransactionDetails.vue')['default']
|
||||
Transactions: typeof import('./src/components/Settings/Transactions.vue')['default']
|
||||
TransactionDetails: typeof import('./src/components/Settings/Transactions/TransactionDetails.vue')['default']
|
||||
TransactionList: typeof import('./src/components/Settings/Transactions/TransactionList.vue')['default']
|
||||
Transactions: typeof import('./src/components/Settings/Transactions/Transactions.vue')['default']
|
||||
UnsplashImageBrowser: typeof import('./src/components/UnsplashImageBrowser.vue')['default']
|
||||
UpcomingEvaluations: typeof import('./src/components/UpcomingEvaluations.vue')['default']
|
||||
Uploader: typeof import('./src/components/Controls/Uploader.vue')['default']
|
||||
UploadPlugin: typeof import('./src/components/UploadPlugin.vue')['default']
|
||||
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
|
||||
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
|
||||
UserDropdown: typeof import('./src/components/Sidebar/UserDropdown.vue')['default']
|
||||
VideoBlock: typeof import('./src/components/VideoBlock.vue')['default']
|
||||
VideoStatistics: typeof import('./src/components/Modals/VideoStatistics.vue')['default']
|
||||
ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default']
|
||||
|
||||
+47
-45
@@ -6,55 +6,57 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"serve": "vite preview",
|
||||
"build": "vite build --base=/assets/lms/frontend/ && yarn copy-html-entry",
|
||||
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html"
|
||||
"build": "vite build --base=/assets/lms/frontend/ && yarn copy-html-entry && yarn copy-colors-json",
|
||||
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/_lms.html",
|
||||
"copy-colors-json": "cp node_modules/frappe-ui/tailwind/colors.json src/utils/frappe-ui-colors.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@editorjs/checklist": "^1.6.0",
|
||||
"@editorjs/code": "^2.9.0",
|
||||
"@editorjs/editorjs": "^2.29.0",
|
||||
"@editorjs/embed": "^2.7.0",
|
||||
"@editorjs/header": "^2.8.1",
|
||||
"@editorjs/inline-code": "^1.5.0",
|
||||
"@editorjs/nested-list": "^1.4.2",
|
||||
"@editorjs/paragraph": "^2.11.3",
|
||||
"@editorjs/simple-image": "^1.6.0",
|
||||
"@editorjs/table": "^2.4.2",
|
||||
"@vueuse/router": "^12.7.0",
|
||||
"ace-builds": "^1.36.2",
|
||||
"apexcharts": "^4.3.0",
|
||||
"chart.js": "^4.4.1",
|
||||
"codemirror": "^6.0.1",
|
||||
"dayjs": "^1.11.6",
|
||||
"dompurify": "^3.2.6",
|
||||
"feather-icons": "^4.28.0",
|
||||
"frappe-ui": "^0.1.201",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-vue-next": "^0.383.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
"pinia": "^2.0.33",
|
||||
"plyr": "^3.7.8",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"tailwindcss": "3.4.15",
|
||||
"thememirror": "^2.0.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vue": "^3.4.23",
|
||||
"vue-chartjs": "^5.3.0",
|
||||
"vue-codemirror": "^6.1.1",
|
||||
"vue-draggable-next": "^2.2.1",
|
||||
"vue-router": "^4.0.12",
|
||||
"vue3-apexcharts": "^1.8.0",
|
||||
"@codemirror/lang-html": "6.4.9",
|
||||
"@codemirror/lang-javascript": "6.2.4",
|
||||
"@codemirror/lang-json": "6.0.1",
|
||||
"@codemirror/lang-python": "6.2.1",
|
||||
"@editorjs/checklist": "1.6.0",
|
||||
"@editorjs/code": "2.9.0",
|
||||
"@editorjs/editorjs": "2.29.0",
|
||||
"@editorjs/embed": "2.7.0",
|
||||
"@editorjs/header": "2.8.1",
|
||||
"@editorjs/inline-code": "1.5.0",
|
||||
"@editorjs/nested-list": "1.4.2",
|
||||
"@editorjs/paragraph": "2.11.3",
|
||||
"@editorjs/simple-image": "1.6.0",
|
||||
"@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",
|
||||
"highlight.js": "11.11.1",
|
||||
"lucide-vue-next": "0.383.0",
|
||||
"markdown-it": "14.0.0",
|
||||
"pinia": "2.0.33",
|
||||
"plyr": "3.7.8",
|
||||
"socket.io-client": "4.7.2",
|
||||
"thememirror": "2.0.1",
|
||||
"typescript": "5.7.2",
|
||||
"vue": "^3.5.27",
|
||||
"vue-chartjs": "5.3.0",
|
||||
"vue-codemirror": "6.1.1",
|
||||
"vue-draggable-next": "2.2.1",
|
||||
"vue-router": "^4.6.4",
|
||||
"vue3-apexcharts": "1.8.0",
|
||||
"vuedraggable": "4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.3",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"postcss": "^8.4.5",
|
||||
"vite": "^5.0.11",
|
||||
"vite-plugin-pwa": "^1.0.2"
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
+4
-11
@@ -3,18 +3,17 @@
|
||||
<Layout class="isolate text-base">
|
||||
<router-view />
|
||||
</Layout>
|
||||
<InstallPrompt v-if="isMobile" />
|
||||
<InstallPrompt v-if="isMobile && !settings.data?.disable_pwa" />
|
||||
<Dialogs />
|
||||
</FrappeUIProvider>
|
||||
</template>
|
||||
<script setup>
|
||||
import { FrappeUIProvider } from 'frappe-ui'
|
||||
import { Dialogs } from '@/utils/dialogs'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { useScreenSize } from './utils/composables'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { posthogSettings } from '@/telemetry'
|
||||
import DesktopLayout from './components/DesktopLayout.vue'
|
||||
import MobileLayout from './components/MobileLayout.vue'
|
||||
import NoSidebarLayout from './components/NoSidebarLayout.vue'
|
||||
@@ -23,7 +22,7 @@ import InstallPrompt from './components/InstallPrompt.vue'
|
||||
const { isMobile } = useScreenSize()
|
||||
const router = useRouter()
|
||||
const noSidebar = ref(false)
|
||||
const { userResource } = usersStore()
|
||||
const { settings } = useSettings()
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (to.query.fromLesson || to.path === '/persona') {
|
||||
@@ -47,10 +46,4 @@ const Layout = computed(() => {
|
||||
onUnmounted(() => {
|
||||
noSidebar.value = false
|
||||
})
|
||||
|
||||
watch(userResource, () => {
|
||||
if (userResource.data) {
|
||||
posthogSettings.reload()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,152 +0,0 @@
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
font-display: swap;
|
||||
src: url("Inter-Thin.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Thin.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 100;
|
||||
font-display: swap;
|
||||
src: url("Inter-ThinItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ThinItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraLight.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraLight.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 200;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraLightItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraLightItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url("Inter-Light.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Light.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url("Inter-LightItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-LightItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("Inter-Regular.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Regular.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("Inter-Italic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Italic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("Inter-Medium.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Medium.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("Inter-MediumItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-MediumItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("Inter-SemiBold.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-SemiBold.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("Inter-SemiBoldItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-SemiBoldItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("Inter-Bold.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Bold.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("Inter-BoldItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-BoldItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraBold.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraBold.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraBoldItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraBoldItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("Inter-Black.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Black.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("Inter-BlackItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-BlackItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<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>
|
||||
@@ -26,28 +26,52 @@
|
||||
v-model="quiz"
|
||||
doctype="LMS Quiz"
|
||||
:label="__('Select a quiz')"
|
||||
placeholder=" "
|
||||
:onCreate="(value, close) => redirectToForm()"
|
||||
/>
|
||||
<Link
|
||||
v-else
|
||||
v-model="assignment"
|
||||
doctype="LMS Assignment"
|
||||
:label="__('Select an assignment')"
|
||||
:onCreate="(value, close) => redirectToForm()"
|
||||
/>
|
||||
<div v-else class="space-y-4">
|
||||
<Link
|
||||
v-if="filterAssignmentsByCourse"
|
||||
v-model="assignment"
|
||||
doctype="LMS Assignment"
|
||||
:filters="{
|
||||
course: route.params.courseName,
|
||||
}"
|
||||
placeholder=" "
|
||||
:label="__('Select an Assignment')"
|
||||
:onCreate="(value, close) => redirectToForm()"
|
||||
/>
|
||||
<Link
|
||||
v-else
|
||||
v-model="assignment"
|
||||
doctype="LMS Assignment"
|
||||
placeholder=" "
|
||||
:label="__('Select an Assignment')"
|
||||
:onCreate="(value, close) => redirectToForm()"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
:label="__('Filter assignments by course')"
|
||||
v-model="filterAssignmentsByCourse"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog } from 'frappe-ui'
|
||||
import { onMounted, ref, nextTick } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { Dialog, FormControl } 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'
|
||||
|
||||
const show = ref(false)
|
||||
const quiz = ref(null)
|
||||
const assignment = ref(null)
|
||||
const filterAssignmentsByCourse = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
@@ -71,7 +95,10 @@ const addAssessment = () => {
|
||||
}
|
||||
|
||||
const redirectToForm = () => {
|
||||
if (props.type == 'quiz') window.open('/lms/quizzes/new', '_blank')
|
||||
else window.open('/lms/assignments/new', '_blank')
|
||||
if (props.type == 'quiz') {
|
||||
window.open(getLmsRoute('quizzes?new=true'), '_blank')
|
||||
} else {
|
||||
window.open(getLmsRoute('assignments?new=true'), '_blank')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -25,9 +25,9 @@
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex flex-col overflow-y-auto">
|
||||
<div class="p-5 space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold text-ink-gray-9">
|
||||
{{ __('Submission') }}
|
||||
</div>
|
||||
@@ -53,7 +53,7 @@
|
||||
!['Pass', 'Fail'].includes(submissionResource.doc?.status) &&
|
||||
submissionResource.doc?.owner == user.data?.name
|
||||
"
|
||||
class="bg-surface-blue-2 text-ink-blue-2 p-3 rounded-md leading-5 text-sm mb-4"
|
||||
class="bg-surface-blue-2 text-ink-blue-2 p-3 rounded-md leading-5 text-sm"
|
||||
>
|
||||
{{ __("You've successfully submitted the assignment.") }}
|
||||
{{
|
||||
@@ -63,12 +63,17 @@
|
||||
}}
|
||||
{{ __('Feel free to make edits to your submission if needed.') }}
|
||||
</div>
|
||||
<div v-if="showUploader()">
|
||||
<div class="text-xs text-ink-gray-5 mt-1 mb-2">
|
||||
{{ __('Add your assignment as {0}').format(assignment.data.type) }}
|
||||
<div v-if="showUploader()" class="border rounded-lg p-3">
|
||||
<div class="font-semibold mb-2">
|
||||
{{ __('Upload Assignment') }}
|
||||
</div>
|
||||
<div class="text-ink-gray-5 text-sm mt-1 mb-4">
|
||||
{{
|
||||
__('You can only upload {0} files').format(assignment.data.type)
|
||||
}}
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!submissionFile"
|
||||
v-if="!submissionResource.doc?.assignment_attachment"
|
||||
:fileTypes="getType()"
|
||||
:uploadArgs="{
|
||||
private: true,
|
||||
@@ -87,21 +92,24 @@
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else>
|
||||
<div class="flex text-ink-gray-7">
|
||||
<div class="border self-start rounded-md p-2 mr-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5" />
|
||||
</div>
|
||||
<div class="flex items-center text-ink-gray-7">
|
||||
<a
|
||||
:href="submissionFile.file_url"
|
||||
:href="submissionResource.doc.assignment_attachment"
|
||||
target="_blank"
|
||||
class="flex flex-col cursor-pointer !no-underline"
|
||||
class="cursor-pointer !no-underline text-sm leading-5"
|
||||
>
|
||||
<span class="text-sm leading-5">
|
||||
{{ submissionFile.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-ink-gray-5 mt-1">
|
||||
{{ getFileSize(submissionFile.file_size) }}
|
||||
</span>
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5" />
|
||||
</div>
|
||||
<span>
|
||||
{{
|
||||
submissionResource.doc.assignment_attachment
|
||||
.split('/')
|
||||
.pop()
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
<X
|
||||
v-if="canModifyAssignment"
|
||||
@@ -142,13 +150,13 @@
|
||||
user.data?.name == submissionResource.doc?.owner &&
|
||||
submissionResource.doc?.comments
|
||||
"
|
||||
class="mt-8 p-3 bg-surface-blue-2 rounded-md"
|
||||
class="mt-8 p-3 border rounded-lg"
|
||||
>
|
||||
<div class="text-sm text-ink-gray-5 font-medium mb-2">
|
||||
{{ __('Comments by Evaluator') }}:
|
||||
<div class="text-ink-gray-5 mb-4">
|
||||
{{ __('Comments by Evaluator') }}
|
||||
</div>
|
||||
<div
|
||||
class="leading-5 text-ink-gray-9"
|
||||
class="leading-6 text-ink-gray-9"
|
||||
v-html="submissionResource.doc.comments"
|
||||
></div>
|
||||
</div>
|
||||
@@ -179,6 +187,9 @@
|
||||
"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
: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]"
|
||||
/>
|
||||
</div>
|
||||
@@ -201,10 +212,8 @@ import {
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { getFileSize } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const submissionFile = ref(null)
|
||||
const answer = ref(null)
|
||||
const comments = ref(null)
|
||||
const router = useRouter()
|
||||
@@ -263,9 +272,7 @@ const newSubmission = createResource({
|
||||
assignment: props.assignmentID,
|
||||
member: user.data?.name,
|
||||
}
|
||||
if (showUploader()) {
|
||||
doc.assignment_attachment = submissionFile.value.file_url
|
||||
} else {
|
||||
if (!showUploader()) {
|
||||
doc.answer = answer.value
|
||||
}
|
||||
return {
|
||||
@@ -274,19 +281,6 @@ const newSubmission = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const imageResource = createResource({
|
||||
url: 'lms.lms.api.get_file_info',
|
||||
makeParams(values) {
|
||||
return {
|
||||
file_url: values.image,
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
submissionFile.value = data
|
||||
},
|
||||
})
|
||||
|
||||
const submissionResource = createDocumentResource({
|
||||
doctype: 'LMS Assignment Submission',
|
||||
name: props.submissionName,
|
||||
@@ -299,11 +293,6 @@ const submissionResource = createDocumentResource({
|
||||
|
||||
watch(submissionResource, () => {
|
||||
if (submissionResource.doc) {
|
||||
if (submissionResource.doc.assignment_attachment) {
|
||||
imageResource.reload({
|
||||
image: submissionResource.doc.assignment_attachment,
|
||||
})
|
||||
}
|
||||
if (submissionResource.doc.answer) {
|
||||
answer.value = submissionResource.doc.answer
|
||||
}
|
||||
@@ -312,7 +301,10 @@ watch(submissionResource, () => {
|
||||
}
|
||||
if (submissionResource.isDirty) {
|
||||
isDirty.value = true
|
||||
} else if (showUploader() && !submissionFile.value) {
|
||||
} else if (
|
||||
showUploader() &&
|
||||
!submissionResource.doc.assignment_attachment
|
||||
) {
|
||||
isDirty.value = true
|
||||
} else if (!showUploader() && !answer.value) {
|
||||
isDirty.value = true
|
||||
@@ -322,11 +314,17 @@ watch(submissionResource, () => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(submissionFile, () => {
|
||||
if (props.submissionName == 'new' && submissionFile.value) {
|
||||
isDirty.value = true
|
||||
watch(
|
||||
() => submissionResource.doc,
|
||||
() => {
|
||||
if (
|
||||
props.submissionName == 'new' &&
|
||||
submissionResource.doc?.assignment_attachment
|
||||
) {
|
||||
isDirty.value = true
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const submitAssignment = () => {
|
||||
if (props.submissionName != 'new') {
|
||||
@@ -338,13 +336,13 @@ const submitAssignment = () => {
|
||||
submissionResource.setValue.submit(
|
||||
{
|
||||
...submissionResource.doc,
|
||||
assignment_attachment: submissionFile.value?.file_url,
|
||||
evaluator: evaluator,
|
||||
comments: comments.value,
|
||||
answer: answer.value,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
isDirty.value = false
|
||||
toast.success(__('Changes saved successfully'))
|
||||
},
|
||||
}
|
||||
@@ -385,7 +383,7 @@ const addNewSubmission = () => {
|
||||
|
||||
const saveSubmission = (file) => {
|
||||
isDirty.value = true
|
||||
submissionFile.value = file
|
||||
submissionResource.doc.assignment_attachment = file.file_url
|
||||
}
|
||||
|
||||
const markLessonProgress = () => {
|
||||
@@ -436,7 +434,7 @@ const validateFile = (file) => {
|
||||
|
||||
const removeSubmission = () => {
|
||||
isDirty.value = true
|
||||
submissionFile.value = null
|
||||
submissionResource.doc.assignment_attachment = ''
|
||||
}
|
||||
|
||||
const canGradeSubmission = computed(() => {
|
||||
|
||||
@@ -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="font-medium text-ink-gray-9">
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
<Button v-if="canSeeAddButton()" @click="openCourseModal()">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div v-if="user.data?.is_student">
|
||||
<div>
|
||||
<div class="leading-5 mb-4">
|
||||
<div class="leading-5 mb-4 text-ink-gray-7">
|
||||
<div v-if="readOnly">
|
||||
{{ __('Thank you for providing your feedback.') }}
|
||||
<span
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72">
|
||||
<div
|
||||
v-if="batch.data.seat_count && seats_left > 0"
|
||||
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"
|
||||
:class="
|
||||
batch.data.amount || batch.data.courses.length
|
||||
@@ -9,16 +9,16 @@
|
||||
: 'w-fit mb-4'
|
||||
"
|
||||
>
|
||||
{{ seats_left }}
|
||||
<span v-if="seats_left > 1">
|
||||
{{ batch.data.seats_left }}
|
||||
<span v-if="batch.data.seats_left > 1">
|
||||
{{ __('Seats Left') }}
|
||||
</span>
|
||||
<span v-else-if="seats_left == 1">
|
||||
<span v-else-if="batch.data.seats_left == 1">
|
||||
{{ __('Seat Left') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="batch.data.seat_count && seats_left <= 0"
|
||||
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') }}
|
||||
@@ -54,6 +54,7 @@
|
||||
{{ batch.data.timezone }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!readOnlyMode">
|
||||
<router-link
|
||||
v-if="canAccessBatch"
|
||||
@@ -113,7 +114,7 @@
|
||||
{{ __('Enroll Now') }}
|
||||
</Button>
|
||||
<router-link
|
||||
v-if="isModerator"
|
||||
v-if="canEditBatch"
|
||||
:to="{
|
||||
name: 'BatchForm',
|
||||
params: {
|
||||
@@ -190,15 +191,10 @@ const enrollInBatch = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const seats_left = computed(() => {
|
||||
if (props.batch.data?.seat_count) {
|
||||
return props.batch.data?.seat_count - props.batch.data?.students?.length
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const isStudent = computed(() => {
|
||||
return props.batch.data?.students?.includes(user.data?.name)
|
||||
return user.data
|
||||
? props.batch.data?.students?.includes(user.data?.name)
|
||||
: false
|
||||
})
|
||||
|
||||
const isModerator = computed(() => {
|
||||
@@ -209,7 +205,22 @@ 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
|
||||
}
|
||||
return isModerator.value || isStudent.value || isEvaluator.value
|
||||
})
|
||||
|
||||
const canEditBatch = computed(() => {
|
||||
return isModerator.value || isInstructor.value
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,70 +1,8 @@
|
||||
<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: students.data?.length || 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 || 0 }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AxisChart
|
||||
v-if="showProgressChart"
|
||||
:config="{
|
||||
data: chartData,
|
||||
title: __('Batch Summary'),
|
||||
subtitle: __('Progress of students in courses and assessments'),
|
||||
xAxis: {
|
||||
key: 'task',
|
||||
title: 'Tasks',
|
||||
type: 'category',
|
||||
},
|
||||
yAxis: {
|
||||
title: __('Number of Students'),
|
||||
echartOptions: {
|
||||
minInterval: 1,
|
||||
},
|
||||
},
|
||||
swapXY: true,
|
||||
series: [
|
||||
{
|
||||
name: 'value',
|
||||
type: 'bar',
|
||||
},
|
||||
],
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-ink-gray-7 font-medium">
|
||||
{{ __('Students') }}
|
||||
<div class="text-ink-gray-9 font-medium">
|
||||
{{ studentCount.data ?? 0 }} {{ __('Students') }}
|
||||
</div>
|
||||
<Button v-if="!readOnlyMode" @click="openStudentModal()">
|
||||
<template #prefix>
|
||||
@@ -76,7 +14,8 @@
|
||||
|
||||
<div v-if="students.data?.length">
|
||||
<ListView
|
||||
:columns="getStudentColumns()"
|
||||
class="max-h-[75vh]"
|
||||
:columns="studentColumns"
|
||||
:rows="students.data"
|
||||
row-key="name"
|
||||
:options="{
|
||||
@@ -88,7 +27,7 @@
|
||||
>
|
||||
<ListHeaderItem
|
||||
:item="item"
|
||||
v-for="item in getStudentColumns()"
|
||||
v-for="item in studentColumns"
|
||||
:title="item.label"
|
||||
>
|
||||
<template #prefix="{ item }">
|
||||
@@ -104,7 +43,7 @@
|
||||
<ListRow
|
||||
:row="row"
|
||||
v-for="row in students.data"
|
||||
class="group cursor-pointer"
|
||||
class="group cursor-pointer hover:bg-surface-gray-2 rounded"
|
||||
@click="openStudentProgressModal(row)"
|
||||
>
|
||||
<template #default="{ column, item }">
|
||||
@@ -149,9 +88,14 @@
|
||||
</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 class="text-sm italic text-ink-gray-5">
|
||||
<div v-else-if="!students.loading" class="text-sm italic text-ink-gray-5">
|
||||
{{ __('There are no students in this batch.') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,8 +114,8 @@
|
||||
<script setup>
|
||||
import {
|
||||
Avatar,
|
||||
AxisChart,
|
||||
Button,
|
||||
createListResource,
|
||||
createResource,
|
||||
FeatherIcon,
|
||||
ListHeader,
|
||||
@@ -181,30 +125,17 @@ import {
|
||||
ListRows,
|
||||
ListView,
|
||||
ListRowItem,
|
||||
NumberChart,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import {
|
||||
BookOpen,
|
||||
GraduationCap,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
User,
|
||||
} from 'lucide-vue-next'
|
||||
import { ref, watch } from 'vue'
|
||||
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'
|
||||
import ApexChart from 'vue3-apexcharts'
|
||||
import { theme } from '@/utils/theme'
|
||||
|
||||
const showStudentModal = ref(false)
|
||||
const showStudentProgressModal = ref(false)
|
||||
const selectedStudent = ref(null)
|
||||
const chartData = ref(null)
|
||||
const showProgressChart = ref(false)
|
||||
const assessmentCount = ref(0)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
@@ -214,45 +145,48 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const students = createResource({
|
||||
url: 'lms.lms.utils.get_batch_students',
|
||||
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,
|
||||
onSuccess(data) {
|
||||
chartData.value = getChartData()
|
||||
showProgressChart.value =
|
||||
data.length &&
|
||||
(props.batch?.data?.courses?.length || assessmentCount.value)
|
||||
},
|
||||
})
|
||||
|
||||
const getStudentColumns = () => {
|
||||
let columns = [
|
||||
{
|
||||
label: 'Full Name',
|
||||
key: 'full_name',
|
||||
width: '20rem',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
label: 'Progress',
|
||||
key: 'progress',
|
||||
width: '15rem',
|
||||
icon: 'activity',
|
||||
},
|
||||
{
|
||||
label: 'Last Active',
|
||||
key: 'last_active',
|
||||
width: '10rem',
|
||||
align: 'center',
|
||||
icon: 'clock',
|
||||
},
|
||||
]
|
||||
|
||||
return columns
|
||||
}
|
||||
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
|
||||
@@ -281,6 +215,7 @@ const removeStudents = (selections, unselectAll) => {
|
||||
{
|
||||
onSuccess(data) {
|
||||
students.reload()
|
||||
studentCount.reload()
|
||||
props.batch.reload()
|
||||
toast.success(__('Students deleted successfully'))
|
||||
unselectAll()
|
||||
@@ -288,67 +223,4 @@ const removeStudents = (selections, unselectAll) => {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const getChartData = () => {
|
||||
let tasks = []
|
||||
let data = []
|
||||
|
||||
students.data.forEach((row) => {
|
||||
tasks = countAssessments(row, tasks)
|
||||
tasks = countCourses(row, tasks)
|
||||
})
|
||||
|
||||
tasks.forEach((task) => {
|
||||
data.push({
|
||||
task: task.label,
|
||||
value: task.value,
|
||||
})
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
const countAssessments = (row, tasks) => {
|
||||
Object.keys(row.assessments).forEach((assessment) => {
|
||||
if (row.assessments[assessment].result === 'Pass') {
|
||||
tasks.filter((task) => task.label === assessment).length
|
||||
? tasks.filter((task) => task.label === assessment)[0].value++
|
||||
: tasks.push({
|
||||
value: 1,
|
||||
label: assessment,
|
||||
})
|
||||
}
|
||||
})
|
||||
return tasks
|
||||
}
|
||||
|
||||
const countCourses = (row, tasks) => {
|
||||
Object.keys(row.courses).forEach((course) => {
|
||||
if (row.courses[course] === 100) {
|
||||
tasks.filter((task) => task.label === course).length
|
||||
? tasks.filter((task) => task.label === course)[0].value++
|
||||
: tasks.push({
|
||||
value: 1,
|
||||
label: course,
|
||||
})
|
||||
}
|
||||
})
|
||||
return tasks
|
||||
}
|
||||
|
||||
watch(students, () => {
|
||||
if (students.data?.length) {
|
||||
assessmentCount.value = Object.keys(students.data?.[0].assessments).length
|
||||
}
|
||||
})
|
||||
|
||||
const certificationCount = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
params: {
|
||||
doctype: 'LMS Certificate',
|
||||
filters: {
|
||||
batch_name: props.batch?.data?.name,
|
||||
},
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -68,11 +68,12 @@ const props = defineProps({
|
||||
|
||||
const certification = createResource({
|
||||
url: 'lms.lms.api.get_certification_details',
|
||||
params: {
|
||||
course: props.courseName,
|
||||
makeParams(values) {
|
||||
return {
|
||||
course: props.courseName,
|
||||
}
|
||||
},
|
||||
auto: user.data ? true : false,
|
||||
cache: ['certificationData', user.data?.name],
|
||||
})
|
||||
|
||||
const downloadCertificate = () => {
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="{ size: '2xl' }">
|
||||
<template #body>
|
||||
<div class="text-base">
|
||||
<div class="flex items-center space-x-2 pl-4.5 border-b">
|
||||
<Search class="size-4 text-ink-gray-4" />
|
||||
<input
|
||||
ref="inputRef"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
class="w-full border-none bg-transparent py-3 !pl-2 pr-4.5 text-base text-ink-gray-7 placeholder-ink-gray-4 focus:ring-0"
|
||||
@input="onInput"
|
||||
v-model="query"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="max-h-96 overflow-auto mb-2">
|
||||
<div v-if="query.length" class="mt-5 space-y-5">
|
||||
<CommandPaletteGroup
|
||||
:list="searchResults"
|
||||
@navigateTo="navigateTo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-5 space-y-5">
|
||||
<CommandPaletteGroup
|
||||
:list="jumpToOptions"
|
||||
@navigateTo="navigateTo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center space-x-5 w-full border-t py-2 text-sm text-ink-gray-7 px-4.5"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<MoveUp
|
||||
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
|
||||
/>
|
||||
<MoveDown
|
||||
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
|
||||
/>
|
||||
<span>
|
||||
{{ __('to navigate') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<CornerDownLeft
|
||||
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
|
||||
/>
|
||||
<span>
|
||||
{{ __('to select') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="bg-surface-gray-2 p-1 rounded-sm"> esc </span>
|
||||
<span>
|
||||
{{ __('to close') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { createResource, debounce, Dialog } from 'frappe-ui'
|
||||
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
BookOpen,
|
||||
Briefcase,
|
||||
CornerDownLeft,
|
||||
FileSearch,
|
||||
MoveUp,
|
||||
MoveDown,
|
||||
Search,
|
||||
Users,
|
||||
} from 'lucide-vue-next'
|
||||
import CommandPaletteGroup from './CommandPaletteGroup.vue'
|
||||
|
||||
const show = defineModel<boolean>({ required: true, default: false })
|
||||
const router = useRouter()
|
||||
const query = ref<string>('')
|
||||
const searchResults = ref<Array<any>>([])
|
||||
|
||||
const search = createResource({
|
||||
url: 'lms.command_palette.search_sqlite',
|
||||
makeParams: () => ({
|
||||
query: query.value,
|
||||
}),
|
||||
onSuccess() {
|
||||
generateSearchResults()
|
||||
},
|
||||
})
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
if (query.value.length > 2) {
|
||||
search.reload()
|
||||
}
|
||||
}, 500)
|
||||
|
||||
const onInput = () => {
|
||||
debouncedSearch()
|
||||
}
|
||||
|
||||
const generateSearchResults = () => {
|
||||
search.data?.forEach((type: any) => {
|
||||
let result: { title: string; items: any[] } = { title: '', items: [] }
|
||||
result.title = type.title
|
||||
type.items.forEach((item: any) => {
|
||||
let paramName = item.doctype === 'LMS Course' ? 'courseName' : 'batchName'
|
||||
item.route = {
|
||||
name: item.doctype === 'LMS Course' ? 'CourseDetail' : 'BatchDetail',
|
||||
params: {
|
||||
[paramName]: item.name,
|
||||
},
|
||||
}
|
||||
item.isActive = false
|
||||
})
|
||||
result.items = type.items
|
||||
searchResults.value.push(result)
|
||||
})
|
||||
}
|
||||
|
||||
const appendSearchPage = () => {
|
||||
let searchPage: { title: string; items: Array<any> } = {
|
||||
title: '',
|
||||
items: [],
|
||||
}
|
||||
searchPage.title = __('Jump to')
|
||||
searchPage.items = [
|
||||
{
|
||||
title: __('Search for ') + `"${query.value}"`,
|
||||
route: {
|
||||
name: 'Search',
|
||||
query: {
|
||||
q: query.value,
|
||||
},
|
||||
},
|
||||
icon: FileSearch,
|
||||
isActive: true,
|
||||
},
|
||||
]
|
||||
searchResults.value = [searchPage]
|
||||
}
|
||||
|
||||
watch(
|
||||
query,
|
||||
() => {
|
||||
appendSearchPage()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(show, () => {
|
||||
if (!show.value) {
|
||||
query.value = ''
|
||||
searchResults.value = []
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
addKeyboardShortcuts()
|
||||
})
|
||||
|
||||
const addKeyboardShortcuts = () => {
|
||||
window.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowUp' && show.value) {
|
||||
e.preventDefault()
|
||||
shortcutForArrowKey(-1)
|
||||
} else if (e.key === 'ArrowDown' && show.value) {
|
||||
shortcutForArrowKey(1)
|
||||
} else if (e.key === 'Enter' && show.value) {
|
||||
shortcutForEnter()
|
||||
} else if (e.key === 'Escape' && show.value) {
|
||||
show.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const shortcutForArrowKey = (direction: number) => {
|
||||
let currentList = query.value.length
|
||||
? searchResults.value
|
||||
: jumpToOptions.value
|
||||
let allItems = currentList.flatMap((result: any) => result.items)
|
||||
let indexOfActive = allItems.findIndex((option: any) => option.isActive)
|
||||
let newIndex = indexOfActive + direction
|
||||
if (newIndex < 0) newIndex = allItems.length - 1
|
||||
if (newIndex >= allItems.length) newIndex = 0
|
||||
allItems[indexOfActive].isActive = false
|
||||
allItems[newIndex].isActive = true
|
||||
nextTick(scrollActiveItemIntoView)
|
||||
}
|
||||
|
||||
const scrollActiveItemIntoView = () => {
|
||||
const activeItem = document.querySelector(
|
||||
'.hover\\:bg-surface-gray-2.bg-surface-gray-2'
|
||||
) as HTMLElement
|
||||
if (activeItem) {
|
||||
activeItem.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
}
|
||||
|
||||
const shortcutForEnter = () => {
|
||||
let currentList = query.value.length
|
||||
? searchResults.value
|
||||
: jumpToOptions.value
|
||||
let allItems = currentList.flatMap((result: any) => result.items)
|
||||
let activeOption = allItems.find((option) => option.isActive)
|
||||
if (activeOption) {
|
||||
navigateTo(activeOption.route)
|
||||
}
|
||||
}
|
||||
|
||||
const navigateTo = (route: {
|
||||
name: string
|
||||
params?: Record<string, any>
|
||||
query?: Record<string, any>
|
||||
}) => {
|
||||
show.value = false
|
||||
query.value = ''
|
||||
router.replace({ name: route.name, params: route.params, query: route.query })
|
||||
}
|
||||
|
||||
const jumpToOptions = ref([
|
||||
{
|
||||
title: __('Jump to'),
|
||||
items: [
|
||||
{
|
||||
title: 'Advanced Search',
|
||||
icon: Search,
|
||||
route: {
|
||||
name: 'Search',
|
||||
},
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
title: 'Courses',
|
||||
icon: BookOpen,
|
||||
route: {
|
||||
name: 'Courses',
|
||||
},
|
||||
isActive: false,
|
||||
},
|
||||
{
|
||||
title: 'Batches',
|
||||
icon: Users,
|
||||
route: {
|
||||
name: 'Batches',
|
||||
},
|
||||
isActive: false,
|
||||
},
|
||||
{
|
||||
title: 'Jobs',
|
||||
icon: Briefcase,
|
||||
route: {
|
||||
name: 'Jobs',
|
||||
},
|
||||
isActive: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
</script>
|
||||
<style>
|
||||
mark {
|
||||
background-color: theme('colors.amber.100');
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div v-for="result in list" class="px-2.5 space-y-2">
|
||||
<div class="text-ink-gray-5 px-2">
|
||||
{{ result.title }}
|
||||
</div>
|
||||
<div class="">
|
||||
<div
|
||||
v-for="item in result.items"
|
||||
class="flex items-center justify-between p-2 rounded hover:bg-surface-gray-2 cursor-pointer"
|
||||
:class="{ 'bg-surface-gray-2': item.isActive }"
|
||||
@click="emit('navigateTo', item.route)"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="size-4 stroke-1.5 text-ink-gray-6"
|
||||
/>
|
||||
<div v-html="item.title"></div>
|
||||
</div>
|
||||
<div v-if="item.modified" class="text-ink-gray-5">
|
||||
{{ dayjs.unix(item.modified).fromNow(true) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { inject } from 'vue'
|
||||
|
||||
const dayjs = inject<any>('$dayjs')
|
||||
const emit = defineEmits(['navigateTo'])
|
||||
|
||||
const props = defineProps<{
|
||||
list: Array<{
|
||||
title: string
|
||||
items: Array<{
|
||||
title: string
|
||||
icon?: any
|
||||
isActive?: boolean
|
||||
modified?: string
|
||||
}>
|
||||
}>
|
||||
}>()
|
||||
</script>
|
||||
@@ -48,7 +48,7 @@ const settingsStore = useSettings()
|
||||
|
||||
const sendMail = (close: Function) => {
|
||||
call('frappe.core.doctype.communication.email.make', {
|
||||
recipients: settingsStore.contactUsEmail?.data,
|
||||
recipients: settingsStore.settings?.data?.contact_us_email,
|
||||
subject: subject.value,
|
||||
content: message.value,
|
||||
send_email: true,
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
@click="() => togglePopover()"
|
||||
:disabled="attrs.readonly"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center w-[90%]">
|
||||
<slot name="prefix" />
|
||||
<span
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
|
||||
class="block truncate text-base leading-5"
|
||||
v-if="selectedValue"
|
||||
>
|
||||
{{ displayValue(selectedValue) }}
|
||||
|
||||
@@ -3,59 +3,67 @@
|
||||
<div class="text-xs text-ink-gray-5 mb-2">
|
||||
{{ label }}
|
||||
</div>
|
||||
<div class="overflow-x-auto border rounded-md">
|
||||
<div
|
||||
class="grid items-center space-x-4 p-2 border-b"
|
||||
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
||||
>
|
||||
<div class="overflow-visible border rounded-md">
|
||||
<div class="overflow-x-auto">
|
||||
<div
|
||||
v-for="(column, index) in columns"
|
||||
:key="index"
|
||||
class="text-sm text-ink-gray-5"
|
||||
class="grid items-center space-x-4 p-2 border-b"
|
||||
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
||||
>
|
||||
{{ column }}
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(row, rowIndex) in rows"
|
||||
:key="rowIndex"
|
||||
class="grid items-center space-x-4 p-2"
|
||||
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
||||
>
|
||||
<template v-for="key in Object.keys(row)" :key="key">
|
||||
<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-sm text-sm focus:outline-none"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="relative" ref="menuRef">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="(event: MouseEvent) => toggleMenu(rowIndex, event)"
|
||||
>
|
||||
<template #icon>
|
||||
<Ellipsis
|
||||
class="size-4 text-ink-gray-7 stroke-1.5 cursor-pointer"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-if="menuOpenIndex === rowIndex"
|
||||
class="absolute right-[30px] top-5 mt-1 w-32 bg-surface-white border border-outline-gray-1 rounded-md shadow-sm"
|
||||
v-for="(column, index) in columns"
|
||||
:key="index"
|
||||
class="text-sm text-ink-gray-5"
|
||||
>
|
||||
<button
|
||||
@click="deleteRow(rowIndex)"
|
||||
class="flex items-center space-x-2 w-full text-left px-3 py-2 text-sm text-ink-red-3"
|
||||
{{ column }}
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(row, rowIndex) in rows"
|
||||
:key="rowIndex"
|
||||
class="grid items-center space-x-4 p-2"
|
||||
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
||||
>
|
||||
<template v-for="key in Object.keys(row)" :key="key">
|
||||
<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"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="(event: MouseEvent) => toggleMenu(rowIndex, event)"
|
||||
>
|
||||
<Trash2 class="size-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Delete') }}
|
||||
</span>
|
||||
</button>
|
||||
<template #icon>
|
||||
<Ellipsis
|
||||
class="size-4 text-ink-gray-7 stroke-1.5 cursor-pointer"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<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="
|
||||
rowIndex == (rows?.length ?? 0) - 1
|
||||
? 'bottom-full mb-1'
|
||||
: 'top-full mt-1'
|
||||
"
|
||||
>
|
||||
<button
|
||||
@click="deleteRow(rowIndex)"
|
||||
class="flex items-center space-x-2 w-full text-left px-3 py-2 text-sm text-ink-red-3"
|
||||
>
|
||||
<Trash2 class="size-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Delete') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,17 +81,19 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { Button } from 'frappe-ui'
|
||||
import { Ellipsis, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
|
||||
const rows = defineModel<Cell[][]>()
|
||||
const rows = defineModel<Record<string, string>[]>()
|
||||
const menuRef = ref(null)
|
||||
const menuOpenIndex = ref<number | null>(null)
|
||||
const menuTopPosition = ref<string>('')
|
||||
const menuLeftPosition = ref('0px')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: Cell[][]): void
|
||||
(e: 'update:modelValue', value: Record<string, string>[]): void
|
||||
}>()
|
||||
|
||||
type Cell = {
|
||||
@@ -93,19 +103,19 @@ type Cell = {
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: Cell[][]
|
||||
modelValue?: Record<string, string>[]
|
||||
columns?: string[]
|
||||
label?: string
|
||||
}>(),
|
||||
{
|
||||
columns: [],
|
||||
columns: () => [] as string[],
|
||||
}
|
||||
)
|
||||
|
||||
const columns = ref(props.columns)
|
||||
|
||||
watch(rows, () => {
|
||||
if (rows.value?.length < 1) {
|
||||
if (rows.value && rows.value.length < 1) {
|
||||
addRow()
|
||||
}
|
||||
})
|
||||
@@ -119,12 +129,25 @@ const addRow = () => {
|
||||
newRow[column.toLowerCase().split(' ').join('_')] = ''
|
||||
})
|
||||
rows.value.push(newRow)
|
||||
focusNewRowInput()
|
||||
emit('update:modelValue', rows.value)
|
||||
}
|
||||
|
||||
const focusNewRowInput = () => {
|
||||
nextTick(() => {
|
||||
const rowElements = document.querySelectorAll('.overflow-x-auto .grid')[
|
||||
rows.value!.length
|
||||
]
|
||||
const firstInput = rowElements.querySelector('input')
|
||||
if (firstInput) {
|
||||
;(firstInput as HTMLInputElement).focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const deleteRow = (index: number) => {
|
||||
rows.value.splice(index, 1)
|
||||
emit('update:modelValue', rows.value)
|
||||
rows.value?.splice(index, 1)
|
||||
emit('update:modelValue', rows.value ?? [])
|
||||
}
|
||||
|
||||
const getGridTemplateColumns = () => {
|
||||
@@ -133,7 +156,6 @@ const getGridTemplateColumns = () => {
|
||||
|
||||
const toggleMenu = (index: number, event: MouseEvent) => {
|
||||
menuOpenIndex.value = menuOpenIndex.value === index ? null : index
|
||||
menuTopPosition.value = `${event.clientY + 10}px`
|
||||
}
|
||||
|
||||
onClickOutside(menuRef, () => {
|
||||
|
||||
@@ -107,7 +107,7 @@ async function setLanguageExtension() {
|
||||
if (!languageImport) return
|
||||
|
||||
const module = await languageImport()
|
||||
languageExtension.value = (module as any)[props.language]()
|
||||
languageExtension.value = (module as any)[props.language]?.()
|
||||
|
||||
if (props.completions) {
|
||||
const languageData = (module as any)[`${props.language}Language`]
|
||||
|
||||
@@ -21,8 +21,10 @@
|
||||
:style="
|
||||
modelValue
|
||||
? {
|
||||
backgroundColor:
|
||||
theme.backgroundColor[modelValue.toLowerCase()][400],
|
||||
backgroundColor: getColor(
|
||||
modelValue.toLowerCase(),
|
||||
400
|
||||
),
|
||||
}
|
||||
: {}
|
||||
"
|
||||
@@ -55,8 +57,7 @@
|
||||
:key="color"
|
||||
class="size-5 rounded-full cursor-pointer"
|
||||
:style="{
|
||||
backgroundColor:
|
||||
theme.backgroundColor[color.toLowerCase()][400],
|
||||
backgroundColor: getColor(color.toLowerCase(), 400),
|
||||
}"
|
||||
@click="
|
||||
(e) => {
|
||||
@@ -79,7 +80,7 @@
|
||||
import { Button, FormControl, Popover } from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
import { Palette, X } from 'lucide-vue-next'
|
||||
import { theme } from '@/utils/theme'
|
||||
import { getColor } from '@/utils'
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
class="w-4 h-4 text-ink-gray-7 stroke-1.5"
|
||||
:is="icons.Folder"
|
||||
/>
|
||||
<span v-if="selectedIcon">
|
||||
<span v-if="selectedIcon" class="text-ink-gray-7">
|
||||
{{ selectedIcon }}
|
||||
</span>
|
||||
<span v-else class="text-ink-gray-5">
|
||||
|
||||
@@ -67,6 +67,7 @@ import { watchDebounced } from '@vueuse/core'
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import { useAttrs, computed, ref } from 'vue'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
@@ -103,6 +104,7 @@ const value = computed({
|
||||
|
||||
const autocomplete = ref(null)
|
||||
const text = ref('')
|
||||
const settingsStore = useSettings()
|
||||
|
||||
watchDebounced(
|
||||
() => autocomplete.value?.query,
|
||||
@@ -121,6 +123,16 @@ watchDebounced(
|
||||
{ debounce: 300, immediate: true }
|
||||
)
|
||||
|
||||
watchDebounced(
|
||||
() => settingsStore.isSettingsOpen,
|
||||
(isOpen, wasOpen) => {
|
||||
if (wasOpen && !isOpen) {
|
||||
reload('')
|
||||
}
|
||||
},
|
||||
{ debounce: 200 }
|
||||
)
|
||||
|
||||
const options = createResource({
|
||||
url: 'frappe.desk.search.search_link',
|
||||
cache: [props.doctype, text.value],
|
||||
|
||||
@@ -19,21 +19,36 @@
|
||||
showOptions = true
|
||||
}
|
||||
"
|
||||
@click="
|
||||
(e) => {
|
||||
showOptions = true
|
||||
nextTick(() => {
|
||||
setFocus()
|
||||
})
|
||||
}
|
||||
"
|
||||
@focus="
|
||||
() => {
|
||||
if (!filterOptions.data || filterOptions.data.length === 0) {
|
||||
reload('')
|
||||
}
|
||||
}
|
||||
"
|
||||
autocomplete="off"
|
||||
@focus="() => togglePopover()"
|
||||
@keydown.delete.capture.stop="removeLastValue"
|
||||
/>
|
||||
</template>
|
||||
<template #body="{ isOpen, close }">
|
||||
<div v-show="isOpen">
|
||||
<div
|
||||
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
|
||||
class="flex flex-col mt-1 rounded-lg bg-surface-white py-1 text-base border-2 max-h-[13rem]"
|
||||
>
|
||||
<ComboboxOptions
|
||||
class="my-1 min-h-[6rem] max-h-[12rem] overflow-y-auto px-1.5"
|
||||
class="flex-1 my-1 overflow-y-auto px-1.5"
|
||||
:class="options.length ? 'min-h-[6rem]' : 'min-h-[3.8rem]'"
|
||||
static
|
||||
>
|
||||
<ComboboxOption
|
||||
v-if="options.length"
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option"
|
||||
@@ -47,7 +62,11 @@
|
||||
>
|
||||
<div class="flex flex-col gap-1 p-1">
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
{{ option.description }}
|
||||
{{
|
||||
option.value == option.label
|
||||
? option.description
|
||||
: option.label
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ option.value }}
|
||||
@@ -55,23 +74,22 @@
|
||||
</div>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
<div class="h-10"></div>
|
||||
<div
|
||||
v-if="attrs.onCreate"
|
||||
class="absolute bottom-2 left-1 w-[99%] 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 v-else class="text-ink-gray-7 px-4">
|
||||
{{ __('No results found') }}
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -105,7 +123,7 @@ import {
|
||||
} from '@headlessui/vue'
|
||||
import { createResource, Popover, Button } from 'frappe-ui'
|
||||
import { ref, computed, nextTick, useAttrs } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { set, watchDebounced } from '@vueuse/core'
|
||||
import { X, Plus } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -139,21 +157,20 @@ const props = defineProps({
|
||||
|
||||
const values = defineModel()
|
||||
const attrs = useAttrs()
|
||||
const emails = ref([])
|
||||
const search = ref(null)
|
||||
const error = ref(null)
|
||||
const query = ref('')
|
||||
const text = ref('')
|
||||
const showOptions = ref(false)
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const selectedValue = computed({
|
||||
get: () => query.value || '',
|
||||
set: (val) => {
|
||||
query.value = ''
|
||||
if (val) {
|
||||
showOptions.value = false
|
||||
}
|
||||
val?.value && addValue(val.value)
|
||||
showOptions.value = false
|
||||
emit('update:modelValue', values.value)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -180,7 +197,9 @@ const filterOptions = createResource({
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
return filterOptions.data || []
|
||||
setFocus()
|
||||
const allOptions = filterOptions.data || []
|
||||
return allOptions.filter((option) => !values.value?.includes(option.value))
|
||||
})
|
||||
|
||||
function reload(val) {
|
||||
@@ -223,25 +242,7 @@ const addValue = (value) => {
|
||||
|
||||
const removeValue = (value) => {
|
||||
values.value = values.value.filter((v) => v !== value)
|
||||
}
|
||||
|
||||
const removeLastValue = () => {
|
||||
if (query.value) return
|
||||
|
||||
let emailRef = emails.value[emails.value.length - 1]?.$el
|
||||
if (document.activeElement === emailRef) {
|
||||
values.value.pop()
|
||||
nextTick(() => {
|
||||
if (values.value.length) {
|
||||
emailRef = emails.value[emails.value.length - 1].$el
|
||||
emailRef?.focus()
|
||||
} else {
|
||||
setFocus()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
emailRef?.focus()
|
||||
}
|
||||
emit('update:modelValue', values.value)
|
||||
}
|
||||
|
||||
function setFocus() {
|
||||
|
||||
@@ -2,18 +2,21 @@
|
||||
<div class="mb-4">
|
||||
<div v-if="label" class="text-xs text-ink-gray-5 mb-2">
|
||||
{{ __(label) }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
<span v-if="required" class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!modelValue"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file: File) => saveImage(file)"
|
||||
:fileTypes="[fileType]"
|
||||
:validateFile="(file: File) => validateFile(file, true, type)"
|
||||
@success="(file: File) => saveFile(file)"
|
||||
>
|
||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md w-fit py-7 px-20">
|
||||
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
||||
<component
|
||||
:is="props.type === 'image' ? Image : Video"
|
||||
class="size-5 stroke-1 text-ink-gray-7"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<Button @click="openFileSelector">
|
||||
@@ -28,7 +31,20 @@
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="flex items-center">
|
||||
<img :src="modelValue" class="border rounded-md w-44 h-auto" />
|
||||
<img
|
||||
v-if="type == 'image'"
|
||||
:src="modelValue"
|
||||
:class="[
|
||||
'border object-cover',
|
||||
shape === 'circle'
|
||||
? 'w-20 h-20 rounded-full'
|
||||
: 'w-44 h-auto min-h-20 rounded-md',
|
||||
]"
|
||||
/>
|
||||
<video v-else controls class="border rounded-md w-44 h-auto">
|
||||
<source :src="modelValue" />
|
||||
{{ __('Your browser does not support the video tag.') }}
|
||||
</video>
|
||||
<div class="ml-4">
|
||||
<Button @click="removeImage()">
|
||||
{{ __('Remove') }}
|
||||
@@ -47,7 +63,8 @@
|
||||
<script setup lang="ts">
|
||||
import { validateFile } from '@/utils'
|
||||
import { Button, FileUploader } from 'frappe-ui'
|
||||
import { Image } from 'lucide-vue-next'
|
||||
import { Image, Video } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
@@ -55,18 +72,28 @@ const emit = defineEmits<{
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
modelValue: string | null
|
||||
label?: string
|
||||
description?: string
|
||||
type?: 'image' | 'video'
|
||||
required?: boolean
|
||||
shape?: 'square' | 'circle'
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
label: '',
|
||||
description: '',
|
||||
type: 'image',
|
||||
required: true,
|
||||
shape: 'square',
|
||||
}
|
||||
)
|
||||
|
||||
const saveImage = (file: any) => {
|
||||
const fileType = computed(() => {
|
||||
return props.type === 'image' ? 'image/*' : 'video/*'
|
||||
})
|
||||
|
||||
const saveFile = (file: any) => {
|
||||
emit('update:modelValue', file.file_url)
|
||||
}
|
||||
|
||||
|
||||
@@ -136,11 +136,11 @@
|
||||
import { Award, BookOpen, GraduationCap, Star, Users } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { Tooltip } from 'frappe-ui'
|
||||
import { theme } from '@/utils/theme'
|
||||
import { formatAmount } from '@/utils'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
import colors from '@/utils/frappe-ui-colors.json'
|
||||
|
||||
const { user } = sessionStore()
|
||||
|
||||
@@ -152,19 +152,10 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const getGradientColor = () => {
|
||||
let theme = localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
|
||||
let color = props.course.card_gradient?.toLowerCase() || 'blue'
|
||||
let colorMap = theme.backgroundColor[color]
|
||||
let colorMap = colors[theme][color]
|
||||
return `linear-gradient(to top right, black, ${colorMap[400]})`
|
||||
/* return `bg-gradient-to-br from-${color}-100 via-${color}-200 to-${color}-400` */
|
||||
/* return `linear-gradient(to bottom right, ${colorMap[100]}, ${colorMap[400]})` */
|
||||
/* return `radial-gradient(ellipse at 80% 20%, black 20%, ${colorMap[500]} 100%)` */
|
||||
/* return `radial-gradient(ellipse at 30% 70%, black 50%, ${colorMap[500]} 100%)` */
|
||||
/* return `radial-gradient(ellipse at 80% 20%, ${colorMap[100]} 0%, ${colorMap[300]} 50%, ${colorMap[500]} 100%)` */
|
||||
/* return `conic-gradient(from 180deg at 50% 50%, ${colorMap[100]} 0%, ${colorMap[200]} 50%, ${colorMap[400]} 100%)` */
|
||||
/* return `linear-gradient(135deg, ${colorMap[100]}, ${colorMap[300]}), linear-gradient(120deg, rgba(255,255,255,0.4) 0%, transparent 60%) ` */
|
||||
/* return `radial-gradient(circle at 20% 30%, ${colorMap[100]} 0%, transparent 40%),
|
||||
radial-gradient(circle at 80% 40%, ${colorMap[200]} 0%, transparent 50%),
|
||||
linear-gradient(135deg, ${colorMap[300]} 0%, ${colorMap[400]} 100%);` */
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<CertificationLinks :courseName="course.data.name" class="w-full" />
|
||||
</div>
|
||||
<router-link
|
||||
v-else-if="course.data.paid_course"
|
||||
v-else-if="course.data.paid_course && !isAdmin"
|
||||
:to="{
|
||||
name: 'Billing',
|
||||
params: {
|
||||
@@ -56,14 +56,15 @@
|
||||
</Button>
|
||||
</router-link>
|
||||
<Badge
|
||||
v-else-if="course.data.disable_self_learning"
|
||||
v-else-if="course.data.disable_self_learning && !isAdmin"
|
||||
theme="blue"
|
||||
size="lg"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ __('Contact the Administrator to enroll for this course.') }}
|
||||
{{ __('Contact the Administrator to enroll for this course') }}
|
||||
</Badge>
|
||||
<Button
|
||||
v-else-if="!user.data?.is_moderator && !is_instructor()"
|
||||
v-else-if="!isAdmin"
|
||||
@click="enrollStudent()"
|
||||
variant="solid"
|
||||
class="w-full"
|
||||
@@ -88,40 +89,11 @@
|
||||
</template>
|
||||
{{ __('Get Certificate') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="user.data?.is_moderator || is_instructor()"
|
||||
class="w-full mt-2"
|
||||
size="md"
|
||||
@click="showProgressSummary"
|
||||
>
|
||||
<template #prefix>
|
||||
<TrendingUp class="size-4 stroke-1.5" />
|
||||
{{ __('Progress Summary') }}
|
||||
</template>
|
||||
</Button>
|
||||
<router-link
|
||||
v-if="user?.data?.is_moderator || is_instructor()"
|
||||
:to="{
|
||||
name: 'CourseForm',
|
||||
params: {
|
||||
courseName: course.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="subtle" class="w-full mt-2" size="md">
|
||||
<template #prefix>
|
||||
<Pencil class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Edit') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class="font-medium text-ink-gray-9"
|
||||
:class="{ 'mt-8': !readOnlyMode }"
|
||||
:class="{ 'mt-8': course.data.membership && !readOnlyMode }"
|
||||
>
|
||||
{{ __('This course has:') }}
|
||||
</div>
|
||||
@@ -168,12 +140,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CourseProgressSummary
|
||||
v-if="user.data?.is_moderator || is_instructor()"
|
||||
v-model="showProgressModal"
|
||||
:courseName="course.data.name"
|
||||
:enrollments="course.data.enrollments"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
@@ -189,15 +155,14 @@ import {
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { Badge, Button, call, createResource, toast } from 'frappe-ui'
|
||||
import { formatAmount } from '@/utils/'
|
||||
import { capture } from '@/telemetry'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CertificationLinks from '@/components/CertificationLinks.vue'
|
||||
import CourseProgressSummary from '@/components/Modals/CourseProgressSummary.vue'
|
||||
import { useTelemetry } from 'frappe-ui/frappe'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
const showProgressModal = ref(false)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const { capture } = useTelemetry()
|
||||
|
||||
const props = defineProps({
|
||||
course: {
|
||||
@@ -215,13 +180,17 @@ const video_link = computed(() => {
|
||||
|
||||
function enrollStudent() {
|
||||
if (!user.data) {
|
||||
toast.success(__('You need to login first to enroll for this course'))
|
||||
toast.warning(__('You need to login first to enroll for this course'))
|
||||
setTimeout(() => {
|
||||
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
||||
}, 500)
|
||||
} else {
|
||||
call('lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership', {
|
||||
course: props.course.data.name,
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'LMS Enrollment',
|
||||
course: props.course.data.name,
|
||||
member: user.data.name,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
capture('enrolled_in_course', {
|
||||
@@ -290,7 +259,7 @@ const fetchCertificate = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const showProgressSummary = () => {
|
||||
showProgressModal.value = true
|
||||
}
|
||||
const isAdmin = computed(() => {
|
||||
return user.data?.is_moderator || is_instructor()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
{{ __(title) }}
|
||||
</div>
|
||||
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
|
||||
{{ __('Add Chapter') }}
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
@@ -95,8 +98,8 @@
|
||||
name: allowEdit ? 'LessonForm' : 'Lesson',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: lesson.number.split('.')[0],
|
||||
lessonNumber: lesson.number.split('.')[1],
|
||||
chapterNumber: lesson.number.split('-')[0],
|
||||
lessonNumber: lesson.number.split('-')[1],
|
||||
},
|
||||
}"
|
||||
>
|
||||
@@ -174,6 +177,7 @@ import {
|
||||
FilePenLine,
|
||||
HelpCircle,
|
||||
MonitorPlay,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from 'lucide-vue-next'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -389,8 +393,8 @@ const redirectToChapter = (chapter) => {
|
||||
|
||||
const isActiveLesson = (lessonNumber) => {
|
||||
return (
|
||||
route.params.chapterNumber == lessonNumber.split('.')[0] &&
|
||||
route.params.lessonNumber == lessonNumber.split('.')[1]
|
||||
route.params.chapterNumber == lessonNumber.split('-')[0] &&
|
||||
route.params.lessonNumber == lessonNumber.split('-')[1]
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import AppSidebar from './AppSidebar.vue'
|
||||
import AppSidebar from '@/components/Sidebar/AppSidebar.vue'
|
||||
</script>
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Popover :show="iosInstallMessage" placement="top">
|
||||
<Popover :show="iosInstallMessage" placement="top-start">
|
||||
<template #body>
|
||||
<div
|
||||
class="fixed bottom-[4rem] left-1/2 -translate-x-1/2 z-20 w-[90%] flex flex-col gap-3 rounded bg-blue-100 py-5 drop-shadow-xl"
|
||||
class="fixed top-[20rem] translate-x-1/3 z-20 flex flex-col gap-3 rounded bg-surface-white py-5 drop-shadow-xl"
|
||||
>
|
||||
<div
|
||||
class="mb-1 flex flex-row items-center justify-between px-3 text-center"
|
||||
@@ -41,7 +41,7 @@
|
||||
</div>
|
||||
<div class="px-3 text-xs text-gray-800">
|
||||
<span class="flex flex-col gap-2">
|
||||
<span>
|
||||
<span class="leading-5">
|
||||
{{
|
||||
__(
|
||||
'Get the app on your iPhone for easy access & a better experience'
|
||||
@@ -76,7 +76,14 @@ const isIos = () => {
|
||||
const isInStandaloneMode = () =>
|
||||
'standalone' in window.navigator && window.navigator.standalone
|
||||
|
||||
if (isIos() && !isInStandaloneMode()) iosInstallMessage.value = true
|
||||
if (
|
||||
isIos() &&
|
||||
!isInStandaloneMode() &&
|
||||
localStorage.getItem('learningIosInstallPromptShown') !== 'true'
|
||||
) {
|
||||
iosInstallMessage.value = true
|
||||
localStorage.setItem('learningIosInstallPromptShown', 'true')
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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"
|
||||
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>
|
||||
@@ -107,7 +107,11 @@
|
||||
v-model:reloadLiveClasses="liveClasses"
|
||||
/>
|
||||
|
||||
<LiveClassAttendance v-model="showAttendance" :live_class="attendanceFor" />
|
||||
<LiveClassAttendance
|
||||
v-if="showAttendance"
|
||||
v-model="showAttendance"
|
||||
:live_class="attendanceFor"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createListResource, Button, Tooltip } from 'frappe-ui'
|
||||
|
||||
@@ -80,6 +80,7 @@ onMounted(() => {
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
destructureSidebarLinks()
|
||||
filterLinksToShow(data)
|
||||
addOtherLinks()
|
||||
},
|
||||
@@ -103,6 +104,16 @@ watch(showMenu, (val) => {
|
||||
}
|
||||
})
|
||||
|
||||
const destructureSidebarLinks = () => {
|
||||
let links = []
|
||||
sidebarLinks.value.forEach((link) => {
|
||||
link.items?.forEach((item) => {
|
||||
links.push(item)
|
||||
})
|
||||
})
|
||||
sidebarLinks.value = links
|
||||
}
|
||||
|
||||
const filterLinksToShow = (data) => {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (!parseInt(data[key])) {
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
:label="__('Submission Type')"
|
||||
:required="true"
|
||||
/>
|
||||
<Link
|
||||
v-model="assignment.course"
|
||||
:label="__('Course')"
|
||||
doctype="LMS Course"
|
||||
placeholder=" "
|
||||
/>
|
||||
<div>
|
||||
<div class="text-xs text-ink-gray-5 mb-2">
|
||||
{{ __('Question') }}
|
||||
@@ -66,6 +72,8 @@
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
|
||||
const show = defineModel()
|
||||
const assignments = defineModel<Assignments>('assignments')
|
||||
@@ -74,6 +82,7 @@ interface Assignment {
|
||||
title: string
|
||||
type: string
|
||||
question: string
|
||||
course?: string
|
||||
}
|
||||
|
||||
interface Assignments {
|
||||
@@ -88,6 +97,7 @@ const assignment = reactive({
|
||||
title: '',
|
||||
type: '',
|
||||
question: '',
|
||||
course: '',
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
@@ -106,6 +116,7 @@ watch(
|
||||
assignment.title = row.title
|
||||
assignment.type = row.type
|
||||
assignment.question = row.question
|
||||
assignment.course = row.course || ''
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -113,33 +124,55 @@ watch(
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
const saveAssignment = () => {
|
||||
if (props.assignmentID == 'new') {
|
||||
assignments.value.insert.submit(
|
||||
{
|
||||
...assignment,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
show.value = false
|
||||
toast.success(__('Assignment created successfully'))
|
||||
},
|
||||
}
|
||||
)
|
||||
} else {
|
||||
assignments.value.setValue.submit(
|
||||
{
|
||||
...assignment,
|
||||
name: props.assignmentID,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
show.value = false
|
||||
toast.success(__('Assignment updated successfully'))
|
||||
},
|
||||
}
|
||||
)
|
||||
watch(show, (newVal) => {
|
||||
if (newVal && props.assignmentID === 'new') {
|
||||
assignment.title = ''
|
||||
assignment.type = ''
|
||||
assignment.question = ''
|
||||
}
|
||||
})
|
||||
|
||||
const validateFields = () => {
|
||||
assignment.title = escapeHTML(assignment.title.trim())
|
||||
assignment.question = sanitizeHTML(assignment.question)
|
||||
}
|
||||
|
||||
const saveAssignment = () => {
|
||||
validateFields()
|
||||
if (props.assignmentID == 'new') {
|
||||
createAssignment()
|
||||
} else {
|
||||
updateAssignment()
|
||||
}
|
||||
}
|
||||
|
||||
const createAssignment = () => {
|
||||
assignments.value.insert.submit(
|
||||
{
|
||||
...assignment,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
show.value = false
|
||||
toast.success(__('Assignment created successfully'))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateAssignment = () => {
|
||||
assignments.value.setValue.submit(
|
||||
{
|
||||
...assignment,
|
||||
name: props.assignmentID,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
show.value = false
|
||||
toast.success(__('Assignment updated successfully'))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const assignmentOptions = computed(() => {
|
||||
|
||||
@@ -23,10 +23,8 @@
|
||||
(value, close) => {
|
||||
close()
|
||||
router.push({
|
||||
name: 'CourseForm',
|
||||
params: {
|
||||
courseName: 'new',
|
||||
},
|
||||
name: 'Courses',
|
||||
query: { newCourse: '1' },
|
||||
})
|
||||
}
|
||||
"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<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">
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ student.full_name }}
|
||||
</div>
|
||||
<Badge
|
||||
@@ -36,7 +36,9 @@
|
||||
v-if="Object.keys(student.assessments).length"
|
||||
class="space-y-2 text-sm"
|
||||
>
|
||||
<div class="flex items-center border-b pb-1 font-medium">
|
||||
<div
|
||||
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
|
||||
>
|
||||
<span class="flex-1">
|
||||
{{ __('Assessment') }}
|
||||
</span>
|
||||
@@ -86,7 +88,9 @@
|
||||
v-if="Object.keys(student.courses).length"
|
||||
class="space-y-2 text-sm"
|
||||
>
|
||||
<div class="flex items-center border-b pb-1 font-medium">
|
||||
<div
|
||||
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
|
||||
>
|
||||
<span class="flex-1">
|
||||
{{ __('Courses') }}
|
||||
</span>
|
||||
|
||||
@@ -80,13 +80,13 @@ import {
|
||||
} from 'frappe-ui'
|
||||
import { reactive, watch, inject } from 'vue'
|
||||
import { getFileSize } from '@/utils/'
|
||||
import { capture } from '@/telemetry'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
|
||||
const show = defineModel()
|
||||
const outline = defineModel('outline')
|
||||
const user = inject('$user')
|
||||
const { capture } = useTelemetry()
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Course Progress Summary'),
|
||||
size: '5xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div
|
||||
class="flex flex-col-reverse md:flex-row justify-between md:space-x-10 text-base mt-10"
|
||||
>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center justify-between space-x-5 mb-4">
|
||||
<FormControl
|
||||
v-model="searchFilter"
|
||||
:placeholder="__('Search by Member')"
|
||||
type="text"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="max-h-[70vh] overflow-y-auto">
|
||||
<ListView
|
||||
v-if="progressList.loading || progressList.data?.length"
|
||||
:columns="progressColumns"
|
||||
:rows="progressList.data"
|
||||
rowKey="name"
|
||||
:options="{
|
||||
selectable: false,
|
||||
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 progressColumns"
|
||||
:key="item.key"
|
||||
>
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon
|
||||
:name="item.icon?.toString()"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows v-for="row in progressList.data">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: { username: row.member_username },
|
||||
}"
|
||||
>
|
||||
<ListRow :row="row">
|
||||
<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>
|
||||
{{ row[column.key].toString() }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</router-link>
|
||||
</ListRows>
|
||||
</ListView>
|
||||
<div
|
||||
v-if="progressList.data && progressList.hasNextPage"
|
||||
class="flex justify-center my-5"
|
||||
>
|
||||
<Button @click="progressList.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 self-start w-full space-y-5">
|
||||
<div
|
||||
class="flex flex-col md:flex-row items-center space-y-2 md:space-y-0 md:space-x-4"
|
||||
>
|
||||
<NumberChart
|
||||
class="border rounded-md w-full"
|
||||
:config="{
|
||||
title: __('Enrollments'),
|
||||
value: memberCount || 0,
|
||||
}"
|
||||
/>
|
||||
<NumberChart
|
||||
class="border rounded-md w-full"
|
||||
:config="{
|
||||
title: __('Average Progress %'),
|
||||
value: chartDetails.data?.average_progress || 0,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<DonutChart
|
||||
:config="{
|
||||
data: chartDetails.data?.progress_distribution || [],
|
||||
title: __('Progress Distribution'),
|
||||
categoryColumn: 'category',
|
||||
valueColumn: 'count',
|
||||
colors: [
|
||||
theme.colors.red['400'],
|
||||
theme.colors.amber['400'],
|
||||
theme.colors.pink['400'],
|
||||
theme.colors.blue['400'],
|
||||
theme.colors.green['400'],
|
||||
],
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
createListResource,
|
||||
createResource,
|
||||
Dialog,
|
||||
DonutChart,
|
||||
FeatherIcon,
|
||||
FormControl,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
NumberChart,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { theme } from '@/utils/theme'
|
||||
|
||||
const show = defineModel<boolean>({ default: false })
|
||||
const searchFilter = ref<string | null>(null)
|
||||
type Filters = {
|
||||
course: string | undefined
|
||||
|
||||
member_name?: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
courseName?: string
|
||||
enrollments?: number
|
||||
}>()
|
||||
|
||||
const memberCount = ref<number>(props.enrollments || 0)
|
||||
|
||||
const chartDetails = createResource({
|
||||
url: 'lms.lms.api.get_course_progress_distribution',
|
||||
params: {
|
||||
course: props.courseName,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const progressList = createListResource({
|
||||
doctype: 'LMS Enrollment',
|
||||
filters: {
|
||||
course: props.courseName,
|
||||
},
|
||||
fields: [
|
||||
'name',
|
||||
'member',
|
||||
'member_name',
|
||||
'member_image',
|
||||
'member_username',
|
||||
'progress',
|
||||
],
|
||||
pageLength: 50,
|
||||
auto: true,
|
||||
})
|
||||
|
||||
watch([searchFilter], () => {
|
||||
let filterApplied = false
|
||||
let filters: Filters = {
|
||||
course: props.courseName,
|
||||
}
|
||||
|
||||
if (searchFilter.value) {
|
||||
filters.member_name = ['like', `%${searchFilter.value}%`]
|
||||
filterApplied = true
|
||||
}
|
||||
|
||||
progressList.update({
|
||||
filters: filters,
|
||||
})
|
||||
progressList.reload(
|
||||
{},
|
||||
{
|
||||
onSuccess(data: any[]) {
|
||||
memberCount.value = filterApplied ? data.length : props.enrollments || 0
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const progressColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Member'),
|
||||
key: 'member_name',
|
||||
width: '60%',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
label: __('Progress'),
|
||||
key: 'progress',
|
||||
align: 'right',
|
||||
icon: 'trending-up',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -1,91 +1,85 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: 'Edit your profile',
|
||||
size: '3xl',
|
||||
actions: [
|
||||
{
|
||||
label: 'Save',
|
||||
variant: 'solid',
|
||||
onClick: (close) => saveProfile(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-header>
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
||||
{{ __('Edit Profile') }}
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Badge v-if="isDirty" theme="orange">
|
||||
{{ __('Not Saved') }}
|
||||
</Badge>
|
||||
<div class="pb-5 float-right">
|
||||
<Button variant="solid" @click="saveProfile()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body-content>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<div class="space-y-4">
|
||||
<!-- <Uploader
|
||||
v-model="profile.image.file_url"
|
||||
label="Profile Image"
|
||||
description="Your profile image to help others recognize you."
|
||||
/> -->
|
||||
<div>
|
||||
<div class="text-xs text-ink-gray-5 mb-1">
|
||||
{{ __('Profile Image') }}
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!profile.image"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{
|
||||
uploading
|
||||
? `Uploading ${progress}%`
|
||||
: 'Upload a profile image'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
:src="profile.image.file_url"
|
||||
class="object-cover h-[50px] w-[50px] rounded-full border-4 border-white object-cover"
|
||||
/>
|
||||
<div class="text-base">
|
||||
<div class="grid grid-cols-2 gap-10">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-4">
|
||||
<Uploader
|
||||
v-model="profile.image"
|
||||
:label="__('Profile Image')"
|
||||
:required="true"
|
||||
shape="circle"
|
||||
/>
|
||||
|
||||
<div class="text-base flex flex-col ml-2">
|
||||
<span>
|
||||
{{ profile.image.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-ink-gray-4 mt-1">
|
||||
{{ getFileSize(profile.image.file_size) }}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
@click="removeImage()"
|
||||
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="profile.first_name"
|
||||
:label="__('First Name')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="profile.last_name"
|
||||
:label="__('Last Name')"
|
||||
/>
|
||||
<FormControl v-model="profile.headline" :label="__('Headline')" />
|
||||
|
||||
<FormControl
|
||||
v-model="profile.linkedin"
|
||||
:label="__('LinkedIn ID')"
|
||||
/>
|
||||
<FormControl v-model="profile.github" :label="__('GitHub ID')" />
|
||||
<FormControl
|
||||
v-model="profile.twitter"
|
||||
:label="__('Twitter ID')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormControl v-model="profile.first_name" :label="__('First Name')" />
|
||||
<FormControl v-model="profile.last_name" :label="__('Last Name')" />
|
||||
<FormControl v-model="profile.headline" :label="__('Headline')" />
|
||||
<Link
|
||||
:label="__('Language')"
|
||||
v-model="profile.language"
|
||||
doctype="Language"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Bio') }}
|
||||
</div>
|
||||
<TextEditor
|
||||
:fixedMenu="true"
|
||||
@change="(val) => (profile.bio = val)"
|
||||
:content="profile.bio"
|
||||
editorClass="prose-sm py-2 px-2 min-h-[200px] border-outline-gray-2 hover:border-outline-gray-3 rounded-b-md bg-surface-gray-3"
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
v-model="profile.open_to"
|
||||
type="select"
|
||||
:options="[' ', 'Work', 'Hiring']"
|
||||
:label="__('Open to')"
|
||||
:placeholder="__('Looking for new work or hiring talent?')"
|
||||
/>
|
||||
<Link
|
||||
:label="__('Language')"
|
||||
v-model="profile.language"
|
||||
doctype="Language"
|
||||
/>
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Bio') }}
|
||||
</div>
|
||||
<TextEditor
|
||||
:fixedMenu="true"
|
||||
@change="(val) => (profile.bio = val)"
|
||||
:content="profile.bio"
|
||||
:rows="15"
|
||||
editorClass="prose-sm py-2 px-2 min-h-[280px] border-outline-gray-2 hover:border-outline-gray-3 rounded-b-md bg-surface-gray-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,22 +88,22 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Dialog,
|
||||
FormControl,
|
||||
FileUploader,
|
||||
Badge,
|
||||
Button,
|
||||
createResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
TextEditor,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { getFileSize, decodeEntities } from '@/utils'
|
||||
import { sanitizeHTML } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
const show = defineModel()
|
||||
const reloadProfile = defineModel('reloadProfile')
|
||||
const hasLanguageChanged = ref(false)
|
||||
const isDirty = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
profile: {
|
||||
@@ -124,19 +118,10 @@ const profile = reactive({
|
||||
headline: '',
|
||||
bio: '',
|
||||
image: '',
|
||||
})
|
||||
|
||||
const imageResource = createResource({
|
||||
url: 'lms.lms.api.get_file_info',
|
||||
makeParams(values) {
|
||||
return {
|
||||
file_url: values.image,
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
profile.image = data
|
||||
},
|
||||
open_to: '',
|
||||
linkedin: '',
|
||||
github: '',
|
||||
twitter: '',
|
||||
})
|
||||
|
||||
const updateProfile = createResource({
|
||||
@@ -146,7 +131,7 @@ const updateProfile = createResource({
|
||||
doctype: 'User',
|
||||
name: props.profile.data.name,
|
||||
fieldname: {
|
||||
user_image: profile.image.file_url,
|
||||
user_image: profile.image || null,
|
||||
...profile,
|
||||
},
|
||||
}
|
||||
@@ -156,28 +141,13 @@ const updateProfile = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const saveProfile = (close) => {
|
||||
profile.bio = DOMPurify.sanitize(decodeEntities(profile.bio), {
|
||||
ALLOWED_TAGS: [
|
||||
'b',
|
||||
'i',
|
||||
'em',
|
||||
'strong',
|
||||
'a',
|
||||
'p',
|
||||
'br',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'img',
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'target', 'src'],
|
||||
})
|
||||
const saveProfile = () => {
|
||||
profile.bio = sanitizeHTML(profile.bio)
|
||||
updateProfile.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess() {
|
||||
close()
|
||||
show.value = false
|
||||
reloadProfile.value.reload()
|
||||
if (hasLanguageChanged.value) {
|
||||
hasLanguageChanged.value = false
|
||||
@@ -191,20 +161,26 @@ const saveProfile = (close) => {
|
||||
)
|
||||
}
|
||||
|
||||
const validateFile = (file) => {
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||
return 'Only image file is allowed.'
|
||||
}
|
||||
}
|
||||
|
||||
const saveImage = (file) => {
|
||||
profile.image = file
|
||||
}
|
||||
|
||||
const removeImage = () => {
|
||||
profile.image = null
|
||||
}
|
||||
watch(
|
||||
() => profile,
|
||||
(newVal) => {
|
||||
if (!props.profile.data) return
|
||||
let keys = Object.keys(newVal)
|
||||
keys.splice(keys.indexOf('image'), 1)
|
||||
for (let key of keys) {
|
||||
if (newVal[key] !== props.profile.data[key]) {
|
||||
isDirty.value = true
|
||||
return
|
||||
}
|
||||
}
|
||||
if (profile.image !== props.profile.data.user_image) {
|
||||
isDirty.value = true
|
||||
return
|
||||
}
|
||||
isDirty.value = false
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.profile.data,
|
||||
@@ -215,15 +191,20 @@ watch(
|
||||
profile.headline = newVal.headline
|
||||
profile.language = newVal.language
|
||||
profile.bio = newVal.bio
|
||||
if (newVal.user_image) imageResource.submit({ image: newVal.user_image })
|
||||
profile.open_to = newVal.open_to
|
||||
profile.linkedin = newVal.linkedin
|
||||
profile.github = newVal.github
|
||||
profile.twitter = newVal.twitter
|
||||
profile.image = newVal.user_image
|
||||
isDirty.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => profile.language,
|
||||
(newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
() => {
|
||||
if (profile.language !== props.profile.data.language) {
|
||||
hasLanguageChanged.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Schedule Evaluation'),
|
||||
title: __('Schedule your evaluation'),
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
@@ -14,64 +14,68 @@
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Course') }}
|
||||
<div class="flex flex-col gap-4 text-base max-h-[60vh]">
|
||||
<FormControl
|
||||
v-model="evaluation.course"
|
||||
type="select"
|
||||
:label="__('Course')"
|
||||
:options="getCourses()"
|
||||
/>
|
||||
<div v-if="slots.data?.length" class="space-y-4 overflow-y-auto mt-4">
|
||||
<div class="text-ink-gray-9 font-medium">
|
||||
{{ __('Available Slots') }}
|
||||
</div>
|
||||
<Select v-model="evaluation.course" :options="getCourses()" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Date') }}
|
||||
</div>
|
||||
<FormControl
|
||||
type="date"
|
||||
v-model="evaluation.date"
|
||||
:min="
|
||||
dayjs()
|
||||
.add(dayjs.duration({ days: 1 }))
|
||||
.format('YYYY-MM-DD')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="slots.data?.length">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Select a slot') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div v-for="slot in slots.data">
|
||||
<div
|
||||
class="text-base text-center border rounded-md text-ink-gray-8 bg-surface-gray-3 p-2 cursor-pointer"
|
||||
@click="saveSlot(slot)"
|
||||
:class="{
|
||||
'border-outline-gray-4':
|
||||
evaluation.start_time == slot.start_time,
|
||||
}"
|
||||
>
|
||||
{{ formatTime(slot.start_time) }} -
|
||||
{{ formatTime(slot.end_time) }}
|
||||
<div class="space-y-5">
|
||||
<div v-for="row in slots.data" class="space-y-2">
|
||||
<div class="flex items-center text-ink-gray-7 space-x-2">
|
||||
<Calendar class="size-3" />
|
||||
<div class="text-ink-gray-9">
|
||||
{{ dayjs(row.date).format('DD MMMM YYYY') }}
|
||||
</div>
|
||||
<div>·</div>
|
||||
<div class="text-ink-gray-5">
|
||||
{{ row.day }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div
|
||||
v-for="slot in row.slots"
|
||||
class="text-base text-center border rounded-md text-ink-gray-8 p-2 cursor-pointer text-ink-gray-7 hover:bg-surface-gray-2 hover:border-outline-gray-3"
|
||||
@click="saveSlot(slot, row)"
|
||||
:class="{
|
||||
'border-outline-gray-4 text-ink-gray-9':
|
||||
evaluation.date == row.date &&
|
||||
evaluation.start_time == slot.start_time,
|
||||
}"
|
||||
>
|
||||
{{ formatTime(slot.start_time) }} -
|
||||
{{ formatTime(slot.end_time) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="evaluation.course && evaluation.date"
|
||||
class="text-sm italic text-ink-red-4"
|
||||
>
|
||||
{{ __('No slots available for this date.') }}
|
||||
<div v-else class="text-ink-red-3">
|
||||
{{ __('No slots available for the selected course.') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, createResource, Select, FormControl, toast } from 'frappe-ui'
|
||||
import { reactive, watch, inject } from 'vue'
|
||||
import {
|
||||
call,
|
||||
createResource,
|
||||
dayjs,
|
||||
Dialog,
|
||||
FormControl,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { ref, watch, inject } from 'vue'
|
||||
import { Calendar } from 'lucide-vue-next'
|
||||
import { formatTime } from '@/utils/'
|
||||
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
const show = defineModel()
|
||||
const evaluations = defineModel('reloadEvals')
|
||||
|
||||
@@ -90,7 +94,7 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const evaluation = reactive({
|
||||
const evaluation = ref({
|
||||
course: '',
|
||||
date: '',
|
||||
start_time: '',
|
||||
@@ -100,48 +104,28 @@ const evaluation = reactive({
|
||||
member: user.data.name,
|
||||
})
|
||||
|
||||
const createEvaluation = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Certificate Request',
|
||||
batch_name: values.batch,
|
||||
...values,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function submitEvaluation(close) {
|
||||
createEvaluation.submit(evaluation, {
|
||||
validate() {
|
||||
if (!evaluation.course) {
|
||||
return 'Please select a course.'
|
||||
}
|
||||
if (!evaluation.date) {
|
||||
return 'Please select a date.'
|
||||
}
|
||||
if (!evaluation.start_time) {
|
||||
return 'Please select a slot.'
|
||||
}
|
||||
if (dayjs(evaluation.date).isBefore(dayjs(), 'day')) {
|
||||
return 'Please select a future date.'
|
||||
}
|
||||
if (dayjs(evaluation.date).isAfter(dayjs(props.endDate), 'day')) {
|
||||
return `Please select a date before the end date ${dayjs(
|
||||
props.endDate
|
||||
).format('DD MMMM YYYY')}.`
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
evaluations.value.reload()
|
||||
close()
|
||||
},
|
||||
onError(err) {
|
||||
toast.warning(__(err.messages?.[0] || err))
|
||||
if (!evaluation.value.date || !evaluation.value.start_time) {
|
||||
toast.warning(__('Please select a slot for your evaluation.'), {
|
||||
duration: 10,
|
||||
})
|
||||
return
|
||||
}
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'LMS Certificate Request',
|
||||
batch_name: evaluation.value.batch,
|
||||
...evaluation.value,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
evaluations.value.reload()
|
||||
close()
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err.messages?.[0] || err)
|
||||
toast.warning(__(err.messages?.[0] || err), { duration: 20 })
|
||||
})
|
||||
}
|
||||
|
||||
const getCourses = () => {
|
||||
@@ -156,7 +140,7 @@ const getCourses = () => {
|
||||
}
|
||||
|
||||
if (courses.length === 1) {
|
||||
evaluation.course = courses[0].value
|
||||
evaluation.value.course = courses[0].value
|
||||
}
|
||||
|
||||
return courses
|
||||
@@ -167,34 +151,22 @@ const slots = createResource({
|
||||
makeParams(values) {
|
||||
return {
|
||||
course: values.course,
|
||||
date: values.date,
|
||||
batch: props.batch,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => evaluation.date,
|
||||
(date) => {
|
||||
evaluation.start_time = ''
|
||||
if (date && evaluation.course) {
|
||||
slots.submit(evaluation)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => evaluation.course,
|
||||
() => evaluation.value.course,
|
||||
(course) => {
|
||||
evaluation.date = ''
|
||||
evaluation.start_time = ''
|
||||
slots.reset()
|
||||
slots.reload(evaluation.value)
|
||||
}
|
||||
)
|
||||
|
||||
const saveSlot = (slot) => {
|
||||
evaluation.start_time = slot.start_time
|
||||
evaluation.end_time = slot.end_time
|
||||
evaluation.day = slot.day
|
||||
const saveSlot = (slot, row) => {
|
||||
evaluation.value.start_time = slot.start_time
|
||||
evaluation.value.end_time = slot.end_time
|
||||
evaluation.value.date = row.date
|
||||
evaluation.value.day = row.day
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -22,7 +22,10 @@
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Course')">
|
||||
<div class="flex items-center space-x-2 w-fit">
|
||||
<div
|
||||
class="flex space-x-2 w-fit cursor-pointer"
|
||||
@click="openLink('course', event.course)"
|
||||
>
|
||||
<BookOpen class="h-4 w-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ event.course_title }}
|
||||
@@ -30,7 +33,10 @@
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip v-if="event.batch_title" :text="__('Batch')">
|
||||
<div class="flex items-center space-x-2 w-fit">
|
||||
<div
|
||||
class="flex space-x-2 w-fit cursor-pointer"
|
||||
@click="openLink('batch', event.batch_name)"
|
||||
>
|
||||
<Users class="h-4 w-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ event.batch_title }}
|
||||
@@ -66,7 +72,11 @@
|
||||
</template>
|
||||
{{ __('View Certificate') }}
|
||||
</Button>
|
||||
<Button v-else @click="openCallLink(event.venue)" class="w-full">
|
||||
<Button
|
||||
v-else-if="userIsEvaluator()"
|
||||
@click="openCallLink(event.venue)"
|
||||
class="w-full"
|
||||
>
|
||||
<template #prefix>
|
||||
<Video class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
@@ -83,21 +93,31 @@
|
||||
class="flex flex-col space-y-4 p-5"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<Rating v-model="evaluation.rating" :label="__('Rating')" />
|
||||
<Rating
|
||||
v-model="evaluation.rating"
|
||||
:label="__('Rating')"
|
||||
:disabled="!userIsEvaluator()"
|
||||
/>
|
||||
<FormControl
|
||||
type="select"
|
||||
:options="statusOptions"
|
||||
v-model="evaluation.status"
|
||||
:label="__('Status')"
|
||||
class="w-1/2"
|
||||
:disabled="!userIsEvaluator()"
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
v-model="evaluation.summary"
|
||||
:label="__('Summary')"
|
||||
:rows="7"
|
||||
:disabled="!userIsEvaluator()"
|
||||
/>
|
||||
<Button variant="solid" @click="saveEvaluation()">
|
||||
<Button
|
||||
v-if="userIsEvaluator()"
|
||||
variant="solid"
|
||||
@click="saveEvaluation()"
|
||||
>
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -106,11 +126,13 @@
|
||||
type="checkbox"
|
||||
v-model="certificate.published"
|
||||
:label="__('Published')"
|
||||
:disabled="!userIsEvaluator()"
|
||||
/>
|
||||
<Link
|
||||
v-model="certificate.template"
|
||||
:label="__('Template')"
|
||||
doctype="Print Format"
|
||||
:disabled="!userIsEvaluator()"
|
||||
:filters="{
|
||||
doc_type: 'LMS Certificate',
|
||||
}"
|
||||
@@ -118,14 +140,20 @@
|
||||
<FormControl
|
||||
type="date"
|
||||
v-model="certificate.issue_date"
|
||||
:disabled="!userIsEvaluator()"
|
||||
:label="__('Issue Date')"
|
||||
/>
|
||||
<FormControl
|
||||
type="date"
|
||||
v-model="certificate.expiry_date"
|
||||
:disabled="!userIsEvaluator()"
|
||||
:label="__('Expiry Date')"
|
||||
/>
|
||||
<Button variant="solid" @click="saveCertificate()">
|
||||
<Button
|
||||
v-if="userIsEvaluator()"
|
||||
variant="solid"
|
||||
@click="saveCertificate()"
|
||||
>
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -163,9 +191,12 @@ import Rating from '@/components/Controls/Rating.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = defineModel()
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
const tabIndex = ref(0)
|
||||
const showCertification = ref(false)
|
||||
const evaluation = reactive({})
|
||||
const certificate = reactive({})
|
||||
|
||||
const props = defineProps({
|
||||
event: {
|
||||
@@ -174,9 +205,15 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const evaluation = reactive({})
|
||||
watch(user, () => {
|
||||
if (userIsEvaluator()) {
|
||||
defaultTemplate.reload()
|
||||
}
|
||||
})
|
||||
|
||||
const certificate = reactive({})
|
||||
const userIsEvaluator = () => {
|
||||
return user.data && user.data.name == props.event.evaluator
|
||||
}
|
||||
|
||||
const defaultTemplate = createResource({
|
||||
url: 'frappe.client.get_value',
|
||||
@@ -190,7 +227,6 @@ const defaultTemplate = createResource({
|
||||
},
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
certificate.template = data.value
|
||||
},
|
||||
@@ -304,7 +340,7 @@ const certificateDetails = createResource({
|
||||
}
|
||||
},
|
||||
onError(err) {
|
||||
certificate.template = defaultTemplate.data.value
|
||||
certificate.template = defaultTemplate.data?.value
|
||||
},
|
||||
auto: false,
|
||||
})
|
||||
@@ -347,6 +383,16 @@ const openCertificate = (certificate) => {
|
||||
)
|
||||
}
|
||||
|
||||
const openLink = (type, name) => {
|
||||
let url = ''
|
||||
if (type === 'course') {
|
||||
url = `/lms/courses/${name}`
|
||||
} else if (type === 'batch') {
|
||||
url = `/lms/batches/${name}#students`
|
||||
}
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
const statusOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-4 text-base">
|
||||
<p class="text-ink-gray-9">
|
||||
{{
|
||||
__(
|
||||
@@ -29,6 +29,7 @@
|
||||
<FileUploader
|
||||
:fileTypes="['.pdf']"
|
||||
:validateFile="validateFile"
|
||||
:uploadArgs="{ private: 1 }"
|
||||
@success="
|
||||
(file) => {
|
||||
resume = file
|
||||
@@ -38,6 +39,9 @@
|
||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||
<div class="">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
<template #prefix>
|
||||
<Upload class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{
|
||||
uploading ? `Uploading ${progress}%` : 'Upload your resume'
|
||||
}}
|
||||
@@ -65,7 +69,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, FileUploader, Button, createResource, toast } from 'frappe-ui'
|
||||
import { FileText } from 'lucide-vue-next'
|
||||
import { FileText, Upload } from 'lucide-vue-next'
|
||||
import { ref, inject } from 'vue'
|
||||
import { getFileSize } from '@/utils/'
|
||||
|
||||
@@ -95,7 +99,7 @@ const jobApplication = createResource({
|
||||
doc: {
|
||||
doctype: 'LMS Job Application',
|
||||
user: user.data?.name,
|
||||
resume: resume.value?.file_name,
|
||||
resume: resume.value?.file_url,
|
||||
job: props.job,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
class="text-base"
|
||||
:options="{
|
||||
title: __('Add web page to sidebar'),
|
||||
size: 'lg',
|
||||
@@ -17,15 +16,17 @@
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<Link
|
||||
v-model="page.webpage"
|
||||
doctype="Web Page"
|
||||
:label="__('Web Page')"
|
||||
:filters="{
|
||||
published: 1,
|
||||
}"
|
||||
/>
|
||||
<IconPicker v-model="page.icon" :label="__('Icon')" class="mt-4" />
|
||||
<div class="text-base">
|
||||
<Link
|
||||
v-model="page.webpage"
|
||||
doctype="Web Page"
|
||||
:label="__('Web Page')"
|
||||
:filters="{
|
||||
published: 1,
|
||||
}"
|
||||
/>
|
||||
<IconPicker v-model="page.icon" :label="__('Icon')" class="mt-4" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
openSettings('Members', close)
|
||||
show = false
|
||||
}
|
||||
"
|
||||
/>
|
||||
|
||||
@@ -66,6 +66,7 @@ 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 ZoomAccount {
|
||||
name: string
|
||||
@@ -97,6 +98,7 @@ interface ZoomAccounts {
|
||||
const show = defineModel('show')
|
||||
const user = inject<User | null>('$user')
|
||||
const zoomAccounts = defineModel<ZoomAccounts>('zoomAccounts')
|
||||
const { capture } = useTelemetry()
|
||||
|
||||
const account = reactive({
|
||||
name: '',
|
||||
@@ -117,32 +119,27 @@ const props = defineProps({
|
||||
watch(
|
||||
() => props.accountID,
|
||||
(val) => {
|
||||
if (val != 'new') {
|
||||
zoomAccounts.value?.data.forEach((acc) => {
|
||||
if (acc.name === val) {
|
||||
account.name = acc.name
|
||||
account.enabled = acc.enabled || false
|
||||
account.member = acc.member
|
||||
account.account_id = acc.account_id
|
||||
account.client_id = acc.client_id
|
||||
account.client_secret = acc.client_secret
|
||||
}
|
||||
})
|
||||
if (val === 'new') {
|
||||
account.name = ''
|
||||
account.enabled = false
|
||||
account.member = user?.data?.name || ''
|
||||
account.account_id = ''
|
||||
account.client_id = ''
|
||||
account.client_secret = ''
|
||||
} else if (val && val !== 'new') {
|
||||
const acc = zoomAccounts.value?.data.find((acc) => acc.name === val)
|
||||
if (acc) {
|
||||
account.name = acc.name
|
||||
account.enabled = acc.enabled || false
|
||||
account.member = acc.member
|
||||
account.account_id = acc.account_id
|
||||
account.client_id = acc.client_id
|
||||
account.client_secret = acc.client_secret
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(show, (val) => {
|
||||
if (!val) {
|
||||
account.name = ''
|
||||
account.enabled = false
|
||||
account.member = user?.data?.name || ''
|
||||
account.account_id = ''
|
||||
account.client_id = ''
|
||||
account.client_secret = ''
|
||||
}
|
||||
})
|
||||
|
||||
const saveAccount = (close: () => void) => {
|
||||
if (props.accountID == 'new') {
|
||||
createAccount(close)
|
||||
@@ -159,6 +156,7 @@ const createAccount = (close: () => void) => {
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
capture('zoom_account_linked')
|
||||
zoomAccounts.value?.reload()
|
||||
close()
|
||||
toast.success(__('Zoom Account created successfully'))
|
||||
|
||||
@@ -1,42 +1,50 @@
|
||||
<template>
|
||||
<div class="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 v-if="user.data" class="px-5 py-3">
|
||||
<div>
|
||||
{{ __('You do not have permission to access this page.') }}
|
||||
<div class="bg-surface-white w-full h-full">
|
||||
<div class="w-fit mx-auto mt-56 text-center p-4">
|
||||
<div class="text-3xl font-semibold text-ink-gray-5 pb-4 mb-2 border-b">
|
||||
{{ __('Not Permitted') }}
|
||||
</div>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Courses',
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" class="mt-2">
|
||||
{{ __('Checkout Courses') }}
|
||||
<div v-if="user.data" class="px-5 py-3">
|
||||
<div class="text-ink-gray-5">
|
||||
{{ __('You do not have permission to access this page.') }}
|
||||
</div>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Courses',
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" class="mt-2 w-full">
|
||||
{{ __('Checkout Courses') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="px-5 py-3">
|
||||
<div class="text-ink-gray-5">
|
||||
{{ __('You are not permitted to access this page.') }}
|
||||
</div>
|
||||
<Button @click="redirectToLogin()" class="mt-4 w-full" variant="solid">
|
||||
{{ __('Login') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="px-5 py-3">
|
||||
<div>
|
||||
{{ __('Please login to access this page.') }}
|
||||
</div>
|
||||
<Button @click="redirectToLogin()" class="mt-4">
|
||||
{{ __('Login') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
import { Button } from 'frappe-ui'
|
||||
import { Button, usePageMeta } from 'frappe-ui'
|
||||
import { sessionStore } from '../stores/session'
|
||||
|
||||
const user = inject('$user')
|
||||
const { brand } = sessionStore()
|
||||
|
||||
const redirectToLogin = () => {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: __('Not Permitted'),
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<span
|
||||
class="size-3 rounded-full"
|
||||
:style="{
|
||||
backgroundColor: theme.backgroundColor[color.toLowerCase()][400],
|
||||
backgroundColor: getColor(color.toLowerCase(), 400),
|
||||
}"
|
||||
></span>
|
||||
<span>
|
||||
@@ -55,9 +55,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { NotepadText, Trash2 } from 'lucide-vue-next'
|
||||
import { theme } from '@/utils/theme'
|
||||
import type { Note, Notes } from '@/components/Notes/types'
|
||||
import { blockQuotesClick, highlightText } from '@/utils'
|
||||
import { blockQuotesClick, getColor, highlightText } from '@/utils'
|
||||
|
||||
const user = inject<any>('$user')
|
||||
const show = defineModel()
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
:placeholder="__('Make notes for quick revision. Press / for menu.')"
|
||||
@change="(val: string) => updateNoteText(val)"
|
||||
:editable="true"
|
||||
:uploadArgs="{
|
||||
private: true,
|
||||
}"
|
||||
editorClass="prose prose-sm min-h-[200px] max-w-none"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="border rounded-lg p-3 space-y-2">
|
||||
<div class="text-ink-gray-5">
|
||||
{{ __(title) }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<slot name="prefix" />
|
||||
<div class="font-semibold text-2xl">
|
||||
{{ value }}
|
||||
</div>
|
||||
<slot name="suffix" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
value: number | string
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<Tooltip :text="`${props.progress}%`">
|
||||
<div class="w-full bg-surface-gray-3 rounded-full h-1">
|
||||
<div
|
||||
class="w-full bg-surface-gray-3 rounded-full h-1"
|
||||
:class="$attrs.class"
|
||||
>
|
||||
<div
|
||||
class="bg-surface-gray-7 rounded-full"
|
||||
:class="progressBarHeight"
|
||||
|
||||
@@ -55,8 +55,8 @@
|
||||
|
||||
<div v-if="quiz.data.duration" class="flex flex-col space-x-1 my-4">
|
||||
<div class="mb-2">
|
||||
<span class=""> {{ __('Time') }}: </span>
|
||||
<span class="font-semibold">
|
||||
<span class="text-ink-gray-9"> {{ __('Time') }}: </span>
|
||||
<span class="font-semibold text-ink-gray-9">
|
||||
{{ formatTimer(timer) }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -165,14 +165,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="ml-2"
|
||||
class="ml-2 text-ink-gray-9"
|
||||
v-html="questionDetails.data[`option_${index}`]"
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
v-if="questionDetails.data[`explanation_${index}`]"
|
||||
class="mt-2 text-xs"
|
||||
class="mt-2 text-xs text-ink-gray-7"
|
||||
v-show="showAnswers.length"
|
||||
>
|
||||
{{ questionDetails.data[`explanation_${index}`] }}
|
||||
@@ -260,7 +260,7 @@
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-else class="text-ink-gray-7">
|
||||
{{
|
||||
__(
|
||||
'You got {0}% correct answers with a score of {1} out of {2}'
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
<template>
|
||||
<div class="flex flex-col justify-between h-full">
|
||||
<div class="flex flex-col h-full">
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold mb-1 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<Badge
|
||||
v-if="isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
<div class="space-x-2">
|
||||
<Badge
|
||||
v-if="isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
:loading="saveSettings.loading"
|
||||
@click="update"
|
||||
>
|
||||
{{ __('Update') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-ink-gray-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-y-auto">
|
||||
<SettingFields :fields="fields" :data="branding.data" />
|
||||
</div>
|
||||
<div class="flex flex-row-reverse mt-auto">
|
||||
<Button variant="solid" :loading="saveSettings.loading" @click="update">
|
||||
{{ __('Update') }}
|
||||
</Button>
|
||||
<SettingFields :sections="sections" :data="branding.data" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -34,7 +38,7 @@ import { watch, ref } from 'vue'
|
||||
const isDirty = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
sections: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
@@ -65,23 +69,9 @@ const saveSettings = createResource({
|
||||
})
|
||||
|
||||
const update = () => {
|
||||
let fieldsToSave = {}
|
||||
let imageFields = ['favicon', 'banner_image']
|
||||
props.fields.forEach((f) => {
|
||||
if (imageFields.includes(f.name)) {
|
||||
fieldsToSave[f.name] =
|
||||
branding.data[f.name] && branding.data[f.name].file_url
|
||||
? branding.data[f.name].file_url
|
||||
: null
|
||||
} else {
|
||||
fieldsToSave[f.name] = branding.data[f.name]
|
||||
}
|
||||
})
|
||||
|
||||
fieldsToSave['app_logo'] = fieldsToSave['banner_image']
|
||||
saveSettings.submit(
|
||||
{
|
||||
fields: fieldsToSave,
|
||||
fields: getFieldsToSave(),
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
@@ -91,18 +81,36 @@ const update = () => {
|
||||
)
|
||||
}
|
||||
|
||||
watch(branding, (updatedDoc) => {
|
||||
let textFields = []
|
||||
let imageFields = []
|
||||
const getFieldsToSave = () => {
|
||||
let imageFields = ['favicon', 'banner_image']
|
||||
let fieldsToSave = {}
|
||||
|
||||
props.fields.forEach((f) => {
|
||||
if (f.type === 'Upload') {
|
||||
imageFields.push(f.name)
|
||||
} else {
|
||||
textFields.push(f.name)
|
||||
}
|
||||
props.sections.forEach((section) => {
|
||||
section.columns.forEach((column) => {
|
||||
column.fields.forEach((field) => {
|
||||
if (imageFields.includes(field.name)) {
|
||||
fieldsToSave[field.name] =
|
||||
branding.data[field.name] && branding.data[field.name].file_url
|
||||
? branding.data[field.name].file_url
|
||||
: null
|
||||
} else {
|
||||
fieldsToSave[field.name] = branding.data[field.name]
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
fieldsToSave['app_logo'] = fieldsToSave['banner_image']
|
||||
return fieldsToSave
|
||||
}
|
||||
|
||||
watch(branding, (updatedDoc) => {
|
||||
updateDirtyState(updatedDoc)
|
||||
})
|
||||
|
||||
const updateDirtyState = (updatedDoc) => {
|
||||
const { textFields, imageFields } = segregateFields()
|
||||
|
||||
textFields.forEach((field) => {
|
||||
if (updatedDoc.data[field] != updatedDoc.previousData[field]) {
|
||||
isDirty.value = true
|
||||
@@ -111,11 +119,29 @@ watch(branding, (updatedDoc) => {
|
||||
|
||||
imageFields.forEach((field) => {
|
||||
if (
|
||||
updatedDoc.data[field] &&
|
||||
updatedDoc.data[field].file_url != updatedDoc.previousData[field].file_url
|
||||
updatedDoc.data[field]?.file_url !=
|
||||
updatedDoc.previousData[field]?.file_url
|
||||
) {
|
||||
isDirty.value = true
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const segregateFields = () => {
|
||||
let textFields = []
|
||||
let imageFields = []
|
||||
|
||||
props.sections.forEach((section) => {
|
||||
section.columns.forEach((column) => {
|
||||
column.fields.forEach((field) => {
|
||||
if (field.type === 'Upload') {
|
||||
imageFields.push(field.name)
|
||||
} else {
|
||||
textFields.push(field.name)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
return { textFields, imageFields }
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="flex flex-col text-base h-full">
|
||||
<div class="flex items-center space-x-2 mb-8 -ml-1.5">
|
||||
<ChevronLeft
|
||||
class="size-5 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||
@click="emit('updateStep', 'list')"
|
||||
/>
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ data?.name ? __('Edit Coupon') : __('New Coupon') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4 overflow-y-auto">
|
||||
<div>
|
||||
<FormControl
|
||||
v-model="data.enabled"
|
||||
:label="__('Enabled')"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<FormControl
|
||||
v-model="data.code"
|
||||
:label="__('Coupon Code')"
|
||||
:required="true"
|
||||
@input="() => (data.code = data.code.toUpperCase())"
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
v-model="data.discount_type"
|
||||
:label="__('Discount Type')"
|
||||
:required="true"
|
||||
type="select"
|
||||
:options="['Percentage', 'Fixed Amount']"
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
v-model="data.expires_on"
|
||||
:label="__('Expires On')"
|
||||
type="date"
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
v-if="data.discount_type === 'Percentage'"
|
||||
v-model="data.percentage_discount"
|
||||
:required="true"
|
||||
:label="__('Discount Percentage')"
|
||||
type="number"
|
||||
/>
|
||||
<FormControl
|
||||
v-else
|
||||
v-model="data.fixed_amount_discount"
|
||||
:required="true"
|
||||
:label="__('Discount Amount')"
|
||||
type="number"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="data.usage_limit"
|
||||
:label="__('Usage Limit')"
|
||||
type="number"
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
v-model="data.redemptions_count"
|
||||
:label="__('Redemptions Count')"
|
||||
type="number"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="py-8">
|
||||
<div class="font-semibold text-ink-gray-9 mb-2">
|
||||
{{ __('Applicable For') }}
|
||||
</div>
|
||||
<CouponItems ref="couponItems" :data="data" :coupons="coupons" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-auto space-x-2 ml-auto">
|
||||
<Button variant="solid" @click="saveCoupon()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, FormControl, toast } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
import { ChevronLeft } from 'lucide-vue-next'
|
||||
import type { Coupon, Coupons } from './types'
|
||||
import CouponItems from '@/components/Settings/Coupons/CouponItems.vue'
|
||||
|
||||
const couponItems = ref<any>(null)
|
||||
const emit = defineEmits(['updateStep'])
|
||||
|
||||
const props = defineProps<{
|
||||
coupons: Coupons
|
||||
data: Coupon
|
||||
}>()
|
||||
|
||||
const saveCoupon = () => {
|
||||
if (props.data?.name) {
|
||||
editCoupon()
|
||||
} else {
|
||||
createCoupon()
|
||||
}
|
||||
}
|
||||
|
||||
const editCoupon = () => {
|
||||
props.coupons.setValue.submit(
|
||||
{
|
||||
...props.data,
|
||||
},
|
||||
{
|
||||
onSuccess(data: Coupon) {
|
||||
if (couponItems.value) {
|
||||
couponItems.value.saveItems()
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const createCoupon = () => {
|
||||
if (couponItems.value) {
|
||||
let rows = couponItems.value.saveItems()
|
||||
props.data.applicable_items = rows
|
||||
}
|
||||
props.coupons.insert.submit(
|
||||
{
|
||||
...props.data,
|
||||
},
|
||||
{
|
||||
onSuccess(data: Coupon) {
|
||||
toast.success(__('Coupon created successfully'))
|
||||
emit('updateStep', 'details', { ...data })
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.error(err.messages?.[0] || err.message || err)
|
||||
console.error(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user