mirror of
https://github.com/frappe/lms.git
synced 2026-05-06 07:29:32 +03:00
Compare commits
574 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fa4dd7121 | |||
| c2a4ef692f | |||
| 6211c507ff | |||
| ffbe3d6e69 | |||
| 1b13f20ff8 | |||
| ee9c460f8d | |||
| 6e57e246e9 | |||
| 5e18ba3c35 | |||
| 3bb7f51295 | |||
| ad037e5f90 | |||
| 07ca95caa8 | |||
| 47b5b603c7 | |||
| eff914c73b | |||
| e3e19c6252 | |||
| e702853b2f | |||
| 1e7da9b40c | |||
| ad1f516d66 | |||
| 08794c2424 | |||
| 8cff1cf916 | |||
| 7fecbe5799 | |||
| 7c46d8bbfc | |||
| e25b7c67b9 | |||
| 65f4cde5b8 | |||
| 56eb556929 | |||
| 371b7663e9 | |||
| aea1059473 | |||
| bb5e277e9e | |||
| e4e6f047a4 | |||
| 23996da0bc | |||
| dc6e3265c0 | |||
| e3e4b9a648 | |||
| 79d5da75b3 | |||
| 80b802a76b | |||
| c082f1d30d | |||
| 8db8fd489e | |||
| d94d08f7c3 | |||
| 0fe8d3ac74 | |||
| 193374df47 | |||
| d55951525f | |||
| 70a9ee1bd7 | |||
| 2878f0232c | |||
| 189cae3490 | |||
| 2b1df68e78 | |||
| 46c24b1166 | |||
| bc139767a8 | |||
| 0838a9d325 | |||
| aed4c6915d | |||
| 07e9b62467 | |||
| 5de889c71b | |||
| 4278361559 | |||
| f891f72e20 | |||
| cb5d19e523 | |||
| 8b3930169d | |||
| 3e05dcedeb | |||
| aec9556cf0 | |||
| c5236b2e50 | |||
| fa94e5f96a | |||
| 88e86e6cfb | |||
| 04fe73531f | |||
| dc4f188648 | |||
| 411b400d04 | |||
| ec5e45e6c6 | |||
| f5b1feade6 | |||
| 19cb56cb21 | |||
| af08e6842a | |||
| c7ccb2d1c5 | |||
| 2ebb6ca745 | |||
| 1f040c4561 | |||
| ee85af09ed | |||
| 453862a653 | |||
| ff98df6acd | |||
| 75ed9c5ec2 | |||
| 0f7a2d1975 | |||
| a4f8497988 | |||
| 79d82647ef | |||
| 4e003a2490 | |||
| 0d394646d9 | |||
| 071e8dc529 | |||
| def3e3d372 | |||
| 97228e4655 | |||
| a507ab425c | |||
| e1b425ed5b | |||
| e4ad66c226 | |||
| 9003e92d6c | |||
| f244a6c9ff | |||
| 226ed85636 | |||
| 717f9000f2 | |||
| 0d8898576f | |||
| ab1bed8f30 | |||
| 93161b8278 | |||
| 090f26f58f | |||
| 1d04f4fd91 | |||
| abda48eaad | |||
| ad6f24dd7c | |||
| 2fe39ee2ba | |||
| 221ac4fad9 | |||
| 831f119398 | |||
| 540c676206 | |||
| 90d4f32c47 | |||
| 7fe8d6c500 | |||
| 7c1869853f | |||
| 3ece2fc3ec | |||
| f9f17ef8ac | |||
| a263ca9330 | |||
| ab96e354cc | |||
| 3d37461a73 | |||
| b1c68ad4f3 | |||
| 6338a5911f | |||
| 6ebaf0e28b | |||
| 55f01dc313 | |||
| c0df21c076 | |||
| 564d10feb6 | |||
| e1e2c08493 | |||
| cd85c5c57f | |||
| 03e5bae0aa | |||
| a4a0a76ad7 | |||
| 1d2b3b0996 | |||
| c3e3337de4 | |||
| 94a80603b0 | |||
| 42abc678a2 | |||
| 78a9eac356 | |||
| 8100c67a00 | |||
| 1c43e6f857 | |||
| 46c0e86723 | |||
| 21c63722f9 | |||
| f736c896ed | |||
| 0a732da414 | |||
| dc0a4ca45c | |||
| 399c893028 | |||
| ff96120cd4 | |||
| c87c21ce7c | |||
| 2a3a5bc875 | |||
| d8faa43820 | |||
| 37a63c5771 | |||
| 42c88235af | |||
| eb4f348a4c | |||
| b35eda205d | |||
| d26c704389 | |||
| e6dba195ce | |||
| ed657138dc | |||
| 47148353fb | |||
| 9aa8e72dc4 | |||
| b580b38a04 | |||
| c95662a96c | |||
| 15de77c8a6 | |||
| b80f6bcb1a | |||
| 5845308344 | |||
| b660c81a56 | |||
| a02cc1e213 | |||
| 755a69420c | |||
| 36c8c291f1 | |||
| bb1b1f6adc | |||
| 0e9abf91a1 | |||
| 0fd5d6b2b0 | |||
| e926fde159 | |||
| 81e8ff5bff | |||
| c0f5ceacfb | |||
| 79441ae09c | |||
| 3509da679c | |||
| edf3a80d8d | |||
| 0f7322b67d | |||
| c510f28a01 | |||
| 839c8eca6e | |||
| ba01c1e803 | |||
| 5efcaab95a | |||
| 73122d1faa | |||
| 26623ecc25 | |||
| 79c732e357 | |||
| 5b50701b3b | |||
| 71c13d634c | |||
| 029d76cf59 | |||
| 72bc9f9630 | |||
| ce719f8159 | |||
| b62edef938 | |||
| aaa866e3ff | |||
| 15e9e95129 | |||
| 924e118d92 | |||
| e3a70a04a3 | |||
| 0ec9ad0b26 | |||
| b4cd463fef | |||
| c4a64c26cd | |||
| d2d36c75c0 | |||
| c2287a5d08 | |||
| bcd55984a9 | |||
| ecc01825c0 | |||
| 400c950bb7 | |||
| 681923e3f7 | |||
| 89505eac7d | |||
| e7d2594142 | |||
| 0486842bc8 | |||
| 99397ad1f4 | |||
| 847719ab77 | |||
| 7d51da78c9 | |||
| 5f9b93280a | |||
| 5614c72472 | |||
| 2d3ba826cf | |||
| 6ca69ecda9 | |||
| 8ff9cde1e3 | |||
| 9fa73ecca2 | |||
| fdc019c106 | |||
| f4eff5d088 | |||
| ea18d07baf | |||
| ffd7d0e466 | |||
| 080dbdf9cd | |||
| 9533ba3b76 | |||
| 17ebc7ae4f | |||
| b2616817e5 | |||
| 32fe61b965 | |||
| 4e92c700bb | |||
| f1e5ce4499 | |||
| 410f06b2a2 | |||
| 4b701e5638 | |||
| 8919f8933a | |||
| 3617dd04e9 | |||
| 1496add6e4 | |||
| b8a0105d85 | |||
| 3f57a18b3f | |||
| c65e38fd1b | |||
| 8737b29475 | |||
| c25a8896ce | |||
| 4317c2297c | |||
| 6e852cb86f | |||
| 2f468ea0ec | |||
| 9bb9177d36 | |||
| f92b7faf0f | |||
| e94fd2949d | |||
| e1ac29d79f | |||
| f559fa1b32 | |||
| 46ee36a6dc | |||
| ca1b5da8e5 | |||
| 189fc08cdb | |||
| 71b96d836a | |||
| f2807d3e38 | |||
| 9b8721fa87 | |||
| a6abef224c | |||
| 1e646e35a2 | |||
| 6f0c695856 | |||
| 9d71915b7d | |||
| 29faf4d3b8 | |||
| 7730b58a02 | |||
| 8f4bd7afaf | |||
| 0d39f1cce1 | |||
| e18d27e9de | |||
| 52e44bee12 | |||
| 5c03754888 | |||
| b34a23ec48 | |||
| 5511576a65 | |||
| 30632e9b3a | |||
| ee6ee469f4 | |||
| f424fe8bbb | |||
| 515ff5662b | |||
| 0cc68cced9 | |||
| bd321dbab4 | |||
| 9b01dfaa14 | |||
| 7bfbbc5926 | |||
| 8d49252418 | |||
| b70b69eb63 | |||
| 8da726a280 | |||
| 7f95a3eb60 | |||
| 7e0bea60ee | |||
| 74862c131d | |||
| f2f042e0fa | |||
| b8dab3e54a | |||
| 186cd90d42 | |||
| a7598233a7 | |||
| 83b6a02e0f | |||
| 755b0af9d0 | |||
| d821ec56aa | |||
| e8c9510511 | |||
| 674512444e | |||
| 29d11a42df | |||
| 613ee475b7 | |||
| 5a0bbae746 | |||
| 0c0820a826 | |||
| dd8a0d4238 | |||
| 5d8090c0a0 | |||
| 5cc8ef227e | |||
| d0261d178d | |||
| 33635408f5 | |||
| 2fc68d12db | |||
| 791601f573 | |||
| 328804c50e | |||
| dfc138fa00 | |||
| c5e0dee764 | |||
| d3c1890ba1 | |||
| 2d840f3c0c | |||
| da3cd25880 | |||
| b987fa0e27 | |||
| dff5359b08 | |||
| 6d05a39b74 | |||
| d6b79b19bc | |||
| a93571d1e1 | |||
| 9ace1381c6 | |||
| 4d6aec0bca | |||
| d6f2720927 | |||
| c5bd65ab23 | |||
| cbabe5bce1 | |||
| f718f0aa61 | |||
| 76776dbc2f | |||
| 49bd5e6766 | |||
| cfefb2101e | |||
| 857c7c6a55 | |||
| b46d5a1f9c | |||
| e8d8a6feb5 | |||
| 8c68584fc2 | |||
| e2550cca31 | |||
| 6646a83378 | |||
| 1ff071a147 | |||
| 4684411d09 | |||
| d6714e6123 | |||
| 77bdc29b3e | |||
| 952da4d240 | |||
| 51cf663eb7 | |||
| b8ec83c25a | |||
| 0d096257c9 | |||
| 86faf86183 | |||
| c33247e347 | |||
| a47125d0d1 | |||
| 9bda76f5f5 | |||
| fde1c106c5 | |||
| 53f98b2788 | |||
| 6a467ea8e2 | |||
| a8575b7ff0 | |||
| 707bbed8d7 | |||
| f26eec09c4 | |||
| 0a056e101f | |||
| bac875baed | |||
| 496f1c0acd | |||
| 6085471053 | |||
| a72aa1366b | |||
| 83b003a303 | |||
| 62685b93e2 | |||
| 82e5af1dee | |||
| 7d08a76cff | |||
| 61b3bd651d | |||
| cd17b7dcfb | |||
| b6a82c5850 | |||
| 747da123aa | |||
| 7cc2f0c52c | |||
| 2f66dd8046 | |||
| 8458985c28 | |||
| 6a6b4e0139 | |||
| ba394926c5 | |||
| e29c9354fd | |||
| 429d38f771 | |||
| b8283860a7 | |||
| 456e1db6c8 | |||
| 40aae3a2ed | |||
| 4f27b9b763 | |||
| be4934862e | |||
| efda159191 | |||
| a664296fe5 | |||
| 189de76a42 | |||
| 1661389b07 | |||
| e90a730a29 | |||
| 9820db329e | |||
| d572f54e3b | |||
| 97405d4ad8 | |||
| 6beae3496f | |||
| e295424d1d | |||
| 5e96911834 | |||
| fdd8c083e8 | |||
| 45298a6f85 | |||
| 00c4d5b878 | |||
| 7343691bb1 | |||
| 9aaff97f06 | |||
| 226b0fb5d1 | |||
| 549a3281ec | |||
| 27f516e383 | |||
| 62d748b6b3 | |||
| bef52063c9 | |||
| b0ae913b33 | |||
| 57b5240c5c | |||
| 193f014627 | |||
| bd005c82c2 | |||
| 0f516a452b | |||
| 9ebf895733 | |||
| 554e111329 | |||
| 2f5010fbe2 | |||
| e1710eb59e | |||
| d072c6259b | |||
| 80de3ad5e1 | |||
| db7c8499b4 | |||
| 005acc2815 | |||
| d68a362115 | |||
| c583ad72d1 | |||
| ef574047fe | |||
| a7eaaeda95 | |||
| 82d9ea7efc | |||
| a6da65ab99 | |||
| ad76fac579 | |||
| 0fb4e0bc41 | |||
| 68d69d5ccd | |||
| f11059524f | |||
| 735a3f4b00 | |||
| 0a587e5598 | |||
| cb273685a7 | |||
| daacbc7faf | |||
| 711d89b603 | |||
| 3889893b2f | |||
| 7cfae5401e | |||
| 71f3aca623 | |||
| 8c54d77740 | |||
| 2e0f8e91af | |||
| 7c20b9c728 | |||
| 2941c4724f | |||
| dcdffc0aac | |||
| 607103e40e | |||
| 8d3485742b | |||
| f24b0fd22b | |||
| 3731826ffd | |||
| 865634ce82 | |||
| 9923b702e0 | |||
| 49f4c878d6 | |||
| 69e2d628d9 | |||
| 112cc3ac9d | |||
| 4a5f16e1bc | |||
| a893c405d1 | |||
| 5683fd5d7a | |||
| f1014e7452 | |||
| 82f0bb40ef | |||
| 71ff6e01d6 | |||
| 701814060d | |||
| 292b48fbac | |||
| 2e3baff401 | |||
| 7e26bb277f | |||
| 22fb96a00f | |||
| 8752f8038a | |||
| c5bb852227 | |||
| 8d8452f8a3 | |||
| c77fdf55b3 | |||
| c509da8497 | |||
| d5bc012c21 | |||
| 610ec89670 | |||
| a29e1a58a4 | |||
| f0d35ec1d1 | |||
| 3f116a37c2 | |||
| 1086d2219b | |||
| c5998f95ee | |||
| 507938425c | |||
| cd9a6831a7 | |||
| 2fab297745 | |||
| 4925c5bc45 | |||
| f5551603a5 | |||
| 1eb13c9378 | |||
| 2c2e8ca112 | |||
| 4771ebbcfd | |||
| 2a2e937876 | |||
| 08fbcc963d | |||
| 54e9396fdb | |||
| 2b124de4cb | |||
| a0d6b2b6b6 | |||
| 93f019a0d0 | |||
| 5180875ab5 | |||
| 40d83aca36 | |||
| d3a4c211db | |||
| 1223ca8f29 | |||
| 9af9a7d87f | |||
| 5ae5634753 | |||
| f63a4a44a2 | |||
| b95a308f7a | |||
| e8fcd2fa0a | |||
| 5c4385aefd | |||
| 3359d4511c | |||
| 5520f7f083 | |||
| 94f0f79404 | |||
| 8d03b25331 | |||
| f54b63a2a7 | |||
| 2dd2c78b88 | |||
| 361d1c0fd6 | |||
| 5c0faa39b7 | |||
| 78c6bfea83 | |||
| f3eb000c23 | |||
| aa638e9992 | |||
| 8ea178fcad | |||
| fa3be115d7 | |||
| 975f06d956 | |||
| 6b24a23e70 | |||
| 87e588cd1f | |||
| 3462d2f251 | |||
| 92e956a9a2 | |||
| 0e65c2cf76 | |||
| 0adda28674 | |||
| 69f90fb809 | |||
| 23cde1761b | |||
| e8354e9781 | |||
| 315ec3d655 | |||
| 484c3d7402 | |||
| 08b6a9d091 | |||
| 4be47af3ef | |||
| 49e989f39e | |||
| 898a872232 | |||
| e7ce850691 | |||
| cb01e17aa7 | |||
| d7c5ff7098 | |||
| 62b5715b98 | |||
| 593c70affb | |||
| 3a1a7db386 | |||
| af611b1603 | |||
| a5e948bba8 | |||
| e63d83beb5 | |||
| 8fa5c899ff | |||
| af5bce9e34 | |||
| 1ea8705552 | |||
| 61193b71f4 | |||
| 26301c26e9 | |||
| 559338da59 | |||
| 9a7c77c57b | |||
| 63321fe2c8 | |||
| 68848fc642 | |||
| aa7ec019bc | |||
| eb33155db2 | |||
| 3088b14d83 | |||
| bf89f3ba2f | |||
| 2198adf902 | |||
| c5145c6c24 | |||
| 499bcd5281 | |||
| dc4bbdaa55 | |||
| bf19ebd3a8 | |||
| 5a6a7ff646 | |||
| b3c8fbd833 | |||
| f828c76a0f | |||
| 2634a4e316 | |||
| fb0517caa0 | |||
| 90151be166 | |||
| b77c4867e1 | |||
| c1260edb00 | |||
| 41d5ef5fd5 | |||
| 14937fd4fc | |||
| 58826fe30f | |||
| 0da9eec0af | |||
| bb47fd5ba9 | |||
| db49cb2d64 | |||
| 58732148e2 | |||
| 08eb7ef17b | |||
| 8bda7edb7b | |||
| 49d596216d | |||
| faa9c94970 | |||
| c596d1e215 | |||
| 235958e432 | |||
| 7f85dbccec | |||
| 5b69ddf9b5 | |||
| dfb7152aa3 | |||
| 2a311bfb6f | |||
| 0e90627144 | |||
| aac1692058 | |||
| d58d362c72 | |||
| e7f2386d14 | |||
| 79a50d2454 | |||
| 936f82c477 | |||
| 133037698c | |||
| 07c58251a1 | |||
| c88d36df1e | |||
| 08373dc2ab | |||
| 44ca59c64a | |||
| c961923fa0 | |||
| 72cee75474 | |||
| cb3af6fa63 | |||
| 0ff14a959d | |||
| 35adf49015 | |||
| e5f0d55ff0 | |||
| ba395fe982 | |||
| 8ab6776fa9 | |||
| 24bfe69985 | |||
| 6f86b822bf | |||
| af273a9a1c | |||
| 44b7a210ce | |||
| 641d729bd1 | |||
| 944fd6d013 | |||
| c0298f0a70 | |||
| 7ef8aad2c8 | |||
| f59eecd34e | |||
| eab929da47 | |||
| e9f0b12550 |
@@ -11,6 +11,7 @@ cd ./frappe-bench || exit
|
||||
bench -v setup requirements
|
||||
|
||||
echo "Setting Up LMS App..."
|
||||
bench get-app "https://github.com/frappe/payments"
|
||||
bench get-app lms "${GITHUB_WORKSPACE}"
|
||||
|
||||
echo "Setting Up Sites & Database..."
|
||||
|
||||
@@ -38,9 +38,9 @@ jobs:
|
||||
|
||||
- name: Set Branch
|
||||
run: |
|
||||
export APPS_JSON='[{"url": "https://github.com/frappe/lms","branch": "main"}]'
|
||||
export APPS_JSON='[{"url": "https://github.com/frappe/payments","branch": "version-15"},{"url": "https://github.com/frappe/lms","branch": "main"}]'
|
||||
echo "APPS_JSON_BASE64=$(echo $APPS_JSON | base64 -w 0)" >> $GITHUB_ENV
|
||||
echo "FRAPPE_BRANCH=version-15" >> $GITHUB_ENV
|
||||
echo "FRAPPE_BRANCH=version-16" >> $GITHUB_ENV
|
||||
|
||||
- name: Set Image Tag
|
||||
run: |
|
||||
@@ -61,4 +61,4 @@ jobs:
|
||||
ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }}
|
||||
build-args: |
|
||||
"FRAPPE_BRANCH=${{ env.FRAPPE_BRANCH }}"
|
||||
"APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}"
|
||||
"APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}"
|
||||
|
||||
@@ -8,6 +8,7 @@ on:
|
||||
pull_request: {}
|
||||
jobs:
|
||||
tests:
|
||||
name: Server Tests
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -61,6 +62,9 @@ jobs:
|
||||
mkdir -p ~/bench-cache
|
||||
(cd && tar czf ~/bench-cache/bench.tgz frappe-bench)
|
||||
fi
|
||||
- name: add payments app to bench
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: bench get-app https://github.com/frappe/payments
|
||||
- name: add lms app to bench
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: bench get-app lms $GITHUB_WORKSPACE
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: UI
|
||||
name: UI Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -16,14 +16,14 @@ permissions:
|
||||
jobs:
|
||||
|
||||
test:
|
||||
name: UI Tests (Cypress) - ${{ matrix.containers }}
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'frappe' }}
|
||||
timeout-minutes: 60
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
name: UI Tests (Cypress)
|
||||
matrix:
|
||||
containers: [1, 2]
|
||||
|
||||
services:
|
||||
mariadb:
|
||||
@@ -115,6 +115,15 @@ jobs:
|
||||
env:
|
||||
CYPRESS_BASE_URL: http://lms.test:8000
|
||||
CYPRESS_RECORD_KEY: 095366ec-7b9f-41bd-aeec-03bb76d627fe
|
||||
SPLIT: ${{ strategy.job-total }}
|
||||
SPLIT_INDEX: ${{ strategy.job-index }}
|
||||
|
||||
- name: Upload Cypress screenshots if tests fail
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cypress-screenshots-${{ matrix.containers }}
|
||||
path: cypress/screenshots
|
||||
|
||||
- name: Stop server and wait for coverage file
|
||||
run: |
|
||||
|
||||
@@ -152,11 +152,19 @@ You need Docker, docker-compose and git setup on your machine. Refer [Docker doc
|
||||
To setup the repository locally follow the steps mentioned below:
|
||||
|
||||
1. Install bench and setup a `frappe-bench` directory by following the [Installation Steps](https://frappeframework.com/docs/user/en/installation)
|
||||
1. Start the server by running `bench start`
|
||||
1. In a separate terminal window, create a new site by running `bench new-site learning.test`
|
||||
1. Map your site to localhost with the command `bench --site learning.test add-to-hosts`
|
||||
1. Get the Learning app. Run `bench get-app https://github.com/frappe/lms`
|
||||
1. Run `bench --site learning.test install-app lms`.
|
||||
1. Start the server by running
|
||||
```sh
|
||||
$ bench start
|
||||
```
|
||||
1. In a separate terminal window, run the following commands.
|
||||
```sh
|
||||
$ bench new-site learning.test
|
||||
$ bench --site learning.test add-to-hosts
|
||||
$ bench get-app https://github.com/frappe/payments
|
||||
$ bench get-app https://github.com/frappe/lms
|
||||
$ bench --site learning.test install-app lms
|
||||
|
||||
```
|
||||
1. Now open the URL `http://learning.test:8000/lms` in your browser, you should see the app running
|
||||
|
||||
## Learn and connect
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: auto
|
||||
threshold: 1%
|
||||
|
||||
ignore:
|
||||
- "**/test_helper.py"
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig } from "cypress";
|
||||
import cypressSplit from "cypress-split";
|
||||
|
||||
export default defineConfig({
|
||||
projectId: "vandxn",
|
||||
@@ -14,5 +15,12 @@ export default defineConfig({
|
||||
},
|
||||
e2e: {
|
||||
baseUrl: "http://pertest:8000",
|
||||
setupNodeEvents(on, config) {
|
||||
// Splitting tests only works when Cypress Cloud is not orchestrating parallel runs.
|
||||
if (process.env.CYPRESS_CLOUD_PARALLEL !== "1") {
|
||||
cypressSplit(on, config);
|
||||
}
|
||||
return config;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ describe("Batch Creation", () => {
|
||||
|
||||
// Open Settings
|
||||
cy.get("span").contains("Learning").click();
|
||||
cy.get("span").contains("Settings").click();
|
||||
cy.contains('[role="menuitem"]', "Settings").click();
|
||||
|
||||
// Add a new member
|
||||
cy.get("[data-dismissable-layer]")
|
||||
@@ -27,24 +27,24 @@ describe("Batch Creation", () => {
|
||||
cy.get("input[placeholder='Jane']").type(randomName);
|
||||
cy.get("button").contains("Add").click();
|
||||
|
||||
// Open Settings
|
||||
cy.get("span").contains("Learning").click();
|
||||
cy.get("span").contains("Settings").click();
|
||||
|
||||
// Add evaluator
|
||||
// Switch to Evaluators tab
|
||||
cy.get("[data-dismissable-layer]")
|
||||
.find("span")
|
||||
.contains(/^Evaluators$/)
|
||||
.click();
|
||||
|
||||
// Click "New" dropdown and select "New Evaluator"
|
||||
cy.get("[data-dismissable-layer]")
|
||||
.find("button")
|
||||
.contains("New")
|
||||
.click();
|
||||
const randomEvaluator = `evaluator${dateNow}@example.com`;
|
||||
cy.contains('[role="menuitem"]', "New Evaluator").click();
|
||||
|
||||
const randomEvaluator = `evaluator${dateNow}@example.com`;
|
||||
cy.get("input[placeholder='jane@doe.com']").type(randomEvaluator);
|
||||
cy.get("input[placeholder='Jane']").type("Evaluator");
|
||||
cy.get("button").contains("Add").click();
|
||||
cy.wait(500);
|
||||
cy.get("div").contains(randomEvaluator).should("be.visible").click();
|
||||
|
||||
cy.visit("/lms/batches");
|
||||
@@ -52,33 +52,29 @@ describe("Batch Creation", () => {
|
||||
|
||||
// Create a batch
|
||||
cy.get("button").contains("Create").click();
|
||||
cy.get("span").contains("New Batch").click();
|
||||
cy.contains('[role="menuitem"]', "New Batch").click();
|
||||
cy.wait(500);
|
||||
cy.url().should("include", "/batches/new/edit");
|
||||
cy.get("label").contains("Title").type("Test Batch");
|
||||
|
||||
cy.get("label").contains("Start Date").type("2030-10-01");
|
||||
cy.get("label").contains("End Date").type("2030-10-31");
|
||||
cy.get("label").contains("Start Time").type("10:00");
|
||||
cy.get("label").contains("End Time").type("11:00");
|
||||
cy.get("label").contains("Timezone").type("IST");
|
||||
cy.get("label").contains("Seat Count").type("10");
|
||||
cy.get("label").contains("Published").click();
|
||||
|
||||
cy.get("label")
|
||||
.contains("Short Description")
|
||||
.contains("Description")
|
||||
.type("Test Batch Short Description to test the UI");
|
||||
cy.get("div[contenteditable=true").invoke(
|
||||
cy.get("div.ProseMirror").invoke(
|
||||
"text",
|
||||
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||
);
|
||||
|
||||
/* Instructor */
|
||||
cy.get("label")
|
||||
.contains("Instructors")
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get("input").click().type("evaluator");
|
||||
cy.get("input").click().clear().type(randomEvaluator);
|
||||
cy.get("input")
|
||||
.invoke("attr", "aria-controls")
|
||||
.as("instructor_list_id");
|
||||
@@ -90,13 +86,27 @@ describe("Batch Creation", () => {
|
||||
cy.get("[id^=headlessui-combobox-option-").first().click();
|
||||
});
|
||||
});
|
||||
cy.button("Save").click();
|
||||
cy.wait(1000);
|
||||
|
||||
// going to batch settings and publishing the batch
|
||||
cy.url().should("include", "#settings");
|
||||
cy.closeOnboardingModal();
|
||||
cy.contains("label", "Published")
|
||||
.invoke("attr", "for")
|
||||
.then((id) => {
|
||||
cy.get(`#${id}`)
|
||||
.scrollIntoView()
|
||||
.should("be.visible")
|
||||
.click({ force: true });
|
||||
cy.get(`#${id}`).should("have.attr", "aria-checked", "true");
|
||||
});
|
||||
cy.button("Save").click();
|
||||
cy.wait(1000);
|
||||
let batchName;
|
||||
cy.url().then((url) => {
|
||||
console.log(url);
|
||||
batchName = url.split("/").pop();
|
||||
batchName = url.split("/").pop().split("#")[0];
|
||||
cy.wrap(batchName).as("batchName");
|
||||
});
|
||||
cy.wait(500);
|
||||
@@ -108,14 +118,10 @@ describe("Batch Creation", () => {
|
||||
|
||||
cy.url().should("include", "/lms/batches");
|
||||
|
||||
cy.get('[id^="headlessui-radiogroup-v-"]')
|
||||
.find("span")
|
||||
.contains("Upcoming")
|
||||
.should("be.visible")
|
||||
.click();
|
||||
cy.contains('[role="radio"]', "Upcoming").should("be.visible").click();
|
||||
|
||||
cy.get("@batchName").then((batchName) => {
|
||||
cy.get(`a[href='/lms/batches/details/${batchName}'`).within(() => {
|
||||
cy.get(`a[href='/lms/batches/${batchName}'`).within(() => {
|
||||
cy.get("div").contains("Test Batch").should("be.visible");
|
||||
cy.get("div")
|
||||
.contains("Test Batch Short Description to test the UI")
|
||||
@@ -132,7 +138,7 @@ describe("Batch Creation", () => {
|
||||
"be.visible"
|
||||
);
|
||||
});
|
||||
cy.get(`a[href='/lms/batches/details/${batchName}'`).click();
|
||||
cy.get(`a[href='/lms/batches/${batchName}'`).click();
|
||||
});
|
||||
|
||||
cy.get("div").contains("Test Batch").should("be.visible");
|
||||
@@ -154,14 +160,15 @@ describe("Batch Creation", () => {
|
||||
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||
)
|
||||
.should("be.visible");
|
||||
cy.get("button:visible").contains("Manage Batch").click();
|
||||
cy.get("button:visible").contains("Dashboard").click();
|
||||
|
||||
/* Add student to batch */
|
||||
cy.get("button").contains("Students").click();
|
||||
cy.get("button").contains("Add").click();
|
||||
cy.closeOnboardingModal();
|
||||
cy.get("button").contains("Enroll").click();
|
||||
cy.get('div[role="dialog"]')
|
||||
.first()
|
||||
.find("input[id^='headlessui-combobox-input-v-']")
|
||||
.find("div[label='Student']")
|
||||
.find("div")
|
||||
.first()
|
||||
.click();
|
||||
cy.get("input[placeholder='Search']").type(randomEmail);
|
||||
@@ -169,7 +176,7 @@ describe("Batch Creation", () => {
|
||||
cy.get("button").contains("Submit").click();
|
||||
|
||||
// Verify Seat Count
|
||||
cy.get("span").contains("Details").click();
|
||||
cy.get("button:visible").contains("Overview").click();
|
||||
cy.contains("div:visible", "9 Seats Left").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,22 +9,29 @@ describe("Course Creation", () => {
|
||||
|
||||
// Create a course
|
||||
cy.get("button").contains("Create").click();
|
||||
cy.get("span").contains("New Course").click();
|
||||
cy.contains('[role="menuitem"]', "New Course").click();
|
||||
cy.wait(500);
|
||||
|
||||
cy.get("label").contains("Title").type("Test Course");
|
||||
cy.get("label")
|
||||
.contains("Short Introduction")
|
||||
.type("Test Course Short Introduction to test the UI");
|
||||
cy.get("div[contenteditable=true").invoke(
|
||||
cy.get("div.ProseMirror").invoke(
|
||||
"text",
|
||||
"Test Course Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||
);
|
||||
|
||||
cy.fixture("profile.png", "base64").then((fileContent) => {
|
||||
cy.contains("Course Image")
|
||||
.should("exist")
|
||||
.parent()
|
||||
.find('input[type="file"]')
|
||||
.attachFile("profile.png", { force: true });
|
||||
|
||||
/* cy.fixture("profile.png", "base64").then((fileContent) => {
|
||||
expect(fileContent).to.exist;
|
||||
cy.get("div")
|
||||
.contains("Course Image")
|
||||
.siblings("div")
|
||||
.parent()
|
||||
.children('input[type="file"]')
|
||||
.attachFile({
|
||||
fileContent,
|
||||
@@ -32,7 +39,7 @@ describe("Course Creation", () => {
|
||||
mimeType: "image/png",
|
||||
encoding: "base64",
|
||||
});
|
||||
});
|
||||
}); */
|
||||
|
||||
/* Instructor */
|
||||
cy.get("label")
|
||||
@@ -53,8 +60,8 @@ describe("Course Creation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
cy.button("Create").last().click();
|
||||
|
||||
cy.button("Save").last().click();
|
||||
cy.closeOnboardingModal();
|
||||
// Edit Course Details
|
||||
cy.wait(500);
|
||||
cy.get("label")
|
||||
@@ -65,22 +72,19 @@ describe("Course Creation", () => {
|
||||
.contains("Category")
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get("input").click();
|
||||
cy.get("button").click();
|
||||
});
|
||||
cy.get("[id^=headlessui-combobox-option-")
|
||||
.should("be.visible")
|
||||
.first()
|
||||
.click();
|
||||
cy.get("div").contains("Business").click();
|
||||
|
||||
cy.get("label").contains("Published").click();
|
||||
cy.get("label").contains("Published On").type("2021-01-01");
|
||||
cy.button("Save").click();
|
||||
|
||||
// Add Chapter
|
||||
cy.wait(1000);
|
||||
cy.wait(500);
|
||||
cy.button("Add").click();
|
||||
|
||||
cy.wait(1000);
|
||||
cy.wait(500);
|
||||
cy.get("[data-dismissable-layer]")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
@@ -89,12 +93,10 @@ describe("Course Creation", () => {
|
||||
});
|
||||
|
||||
// Add Lesson
|
||||
cy.wait(1000);
|
||||
cy.wait(500);
|
||||
cy.button("Add Lesson").click();
|
||||
cy.wait(1000);
|
||||
cy.wait(500);
|
||||
cy.url().should("include", "/learn/1-1/edit");
|
||||
cy.wait(1000);
|
||||
|
||||
cy.get("label").contains("Title").type("Test Lesson");
|
||||
cy.get("#content .ce-block").type(
|
||||
"{enter}This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||
@@ -102,21 +104,23 @@ describe("Course Creation", () => {
|
||||
cy.button("Save").click();
|
||||
|
||||
// View Course
|
||||
cy.wait(1000);
|
||||
cy.wait(500);
|
||||
cy.visit("/lms/courses");
|
||||
cy.closeOnboardingModal();
|
||||
|
||||
cy.url().should("include", "/lms/courses");
|
||||
cy.get(".grid a:first").within(() => {
|
||||
cy.get("div").contains("Test Course");
|
||||
cy.get("div").contains(
|
||||
"Test Course Short Introduction to test the UI"
|
||||
);
|
||||
cy.get(".bg-cover")
|
||||
.invoke("css", "background-image")
|
||||
.should("include", "/files/profile");
|
||||
});
|
||||
cy.get(".grid a:first").click();
|
||||
cy.get("div")
|
||||
.contains("Test Course")
|
||||
.closest("a")
|
||||
.within(() => {
|
||||
cy.get("div").contains(
|
||||
"Test Course Short Introduction to test the UI"
|
||||
);
|
||||
cy.get(".bg-cover")
|
||||
.invoke("css", "background-image")
|
||||
.should("include", "/files/profile");
|
||||
});
|
||||
cy.get("div").contains("Test Course").closest("a").click();
|
||||
cy.url().should("include", "/lms/courses/test-course");
|
||||
cy.get("div").contains("Test Course");
|
||||
cy.get("div").contains("Test Course Short Introduction to test the UI");
|
||||
@@ -134,7 +138,7 @@ describe("Course Creation", () => {
|
||||
cy.get("[id^=headlessui-disclosure-panel-").within(() => {
|
||||
cy.get("div").contains("Test Lesson").click();
|
||||
});
|
||||
cy.wait(3000);
|
||||
cy.wait(500);
|
||||
|
||||
// View Lesson
|
||||
cy.url().should("include", "/learn/1-1");
|
||||
@@ -145,12 +149,11 @@ describe("Course Creation", () => {
|
||||
);
|
||||
|
||||
// Add Discussion
|
||||
cy.get("span").contains("Community").click();
|
||||
cy.button("New Question").click();
|
||||
cy.wait(500);
|
||||
cy.get("[data-dismissable-layer]").within(() => {
|
||||
cy.get("label").contains("Title").type("Test Discussion");
|
||||
cy.get("div[contenteditable=true]").invoke(
|
||||
cy.get("div.ProseMirror").invoke(
|
||||
"text",
|
||||
"This is a test discussion. This will check if the UI is working properly."
|
||||
);
|
||||
@@ -160,7 +163,7 @@ describe("Course Creation", () => {
|
||||
// View Discussion
|
||||
cy.wait(500);
|
||||
cy.get("div").contains("Test Discussion").click();
|
||||
cy.get("div[contenteditable=true").invoke(
|
||||
cy.get("div.ProseMirror").invoke(
|
||||
"text",
|
||||
"This is a test comment. This will check if the UI is working properly."
|
||||
);
|
||||
@@ -168,5 +171,19 @@ describe("Course Creation", () => {
|
||||
cy.get("div").contains(
|
||||
"This is a test comment. This will check if the UI is working properly."
|
||||
);
|
||||
|
||||
// Delete Course
|
||||
cy.get("div").contains("Test Course").click();
|
||||
cy.get("button").contains("Settings").click();
|
||||
cy.get("header").within(() => {
|
||||
cy.get("svg.lucide.lucide-ellipsis-icon").click();
|
||||
});
|
||||
cy.get("div[role=menu]").within(() => {
|
||||
cy.contains('[role="menuitem"]', "Delete").click();
|
||||
});
|
||||
cy.get("span").contains("Delete").click();
|
||||
cy.wait(500);
|
||||
cy.url().should("include", "/lms/courses");
|
||||
cy.get("div").contains("Test Course").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,15 +72,19 @@ Cypress.Commands.add("paste", { prevSubject: true }, (subject, text) => {
|
||||
|
||||
Cypress.Commands.add("closeOnboardingModal", () => {
|
||||
cy.wait(500);
|
||||
const modalSelector = '[data-testid="onboarding-help-modal"]';
|
||||
cy.get("body").then(($body) => {
|
||||
// Check if any element with class including 'z-50' exists
|
||||
if ($body.find('[class*="z-50"]').length > 0) {
|
||||
cy.get('[class*="z-50"]')
|
||||
.find('button:has(svg[class*="feather-x"])')
|
||||
.realClick();
|
||||
cy.wait(1000);
|
||||
} else {
|
||||
cy.log("Onboarding modal not found, skipping close.");
|
||||
if (!$body.find(modalSelector).length) {
|
||||
cy.log("Onboarding modal not present, skipping close.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip onboarding steps if the button exists, otherwise just close the modal.
|
||||
if ($body.find(`${modalSelector} button:contains("Skip all")`).length) {
|
||||
cy.get(modalSelector).contains("button", "Skip all").click();
|
||||
}
|
||||
|
||||
cy.get(modalSelector).find("button:has(svg.feather-x)").click();
|
||||
cy.get(modalSelector).should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ bench set-redis-socketio-host redis://redis:6379
|
||||
sed -i '/redis/d' ./Procfile
|
||||
sed -i '/watch/d' ./Procfile
|
||||
|
||||
bench get-app payments
|
||||
bench get-app lms
|
||||
|
||||
bench new-site lms.localhost \
|
||||
@@ -32,6 +33,7 @@ bench new-site lms.localhost \
|
||||
--admin-password admin \
|
||||
--no-mariadb-socket
|
||||
|
||||
bench --site lms.localhost install-app payments
|
||||
bench --site lms.localhost install-app lms
|
||||
bench --site lms.localhost set-config developer_mode 1
|
||||
bench --site lms.localhost clear-cache
|
||||
|
||||
Vendored
+4
-13
@@ -8,14 +8,11 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AdminBatchDashboard: typeof import('./src/components/AdminBatchDashboard.vue')['default']
|
||||
Annoucements: typeof import('./src/components/Annoucements.vue')['default']
|
||||
AnnouncementModal: typeof import('./src/components/Modals/AnnouncementModal.vue')['default']
|
||||
AddEvaluatorModal: typeof import('./src/components/Modals/AddEvaluatorModal.vue')['default']
|
||||
Apps: typeof import('./src/components/Sidebar/Apps.vue')['default']
|
||||
AppSidebar: typeof import('./src/components/Sidebar/AppSidebar.vue')['default']
|
||||
AssessmentModal: typeof import('./src/components/Modals/AssessmentModal.vue')['default']
|
||||
AssessmentPlugin: typeof import('./src/components/AssessmentPlugin.vue')['default']
|
||||
Assessments: typeof import('./src/components/Assessments.vue')['default']
|
||||
Assignment: typeof import('./src/components/Assignment.vue')['default']
|
||||
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
|
||||
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
|
||||
@@ -24,16 +21,8 @@ declare module 'vue' {
|
||||
BadgeAssignments: typeof import('./src/components/Settings/BadgeAssignments.vue')['default']
|
||||
BadgeForm: typeof import('./src/components/Settings/BadgeForm.vue')['default']
|
||||
Badges: typeof import('./src/components/Settings/Badges.vue')['default']
|
||||
BatchCard: typeof import('./src/components/BatchCard.vue')['default']
|
||||
BatchCourseModal: typeof import('./src/components/Modals/BatchCourseModal.vue')['default']
|
||||
BatchCourses: typeof import('./src/components/BatchCourses.vue')['default']
|
||||
BatchDashboard: typeof import('./src/components/BatchDashboard.vue')['default']
|
||||
BatchFeedback: typeof import('./src/components/BatchFeedback.vue')['default']
|
||||
BatchOverlay: typeof import('./src/components/BatchOverlay.vue')['default']
|
||||
BatchStudentProgress: typeof import('./src/components/Modals/BatchStudentProgress.vue')['default']
|
||||
BatchStudents: typeof import('./src/components/BatchStudents.vue')['default']
|
||||
BrandSettings: typeof import('./src/components/Settings/BrandSettings.vue')['default']
|
||||
BulkCertificates: typeof import('./src/components/Modals/BulkCertificates.vue')['default']
|
||||
Categories: typeof import('./src/components/Settings/Categories.vue')['default']
|
||||
CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default']
|
||||
ChapterModal: typeof import('./src/components/Modals/ChapterModal.vue')['default']
|
||||
@@ -72,6 +61,8 @@ declare module 'vue' {
|
||||
ExplanationVideos: typeof import('./src/components/Modals/ExplanationVideos.vue')['default']
|
||||
FeedbackModal: typeof import('./src/components/Modals/FeedbackModal.vue')['default']
|
||||
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
|
||||
GoogleMeetAccountModal: typeof import('./src/components/Settings/GoogleMeetAccountModal.vue')['default']
|
||||
GoogleMeetSettings: typeof import('./src/components/Settings/GoogleMeetSettings.vue')['default']
|
||||
IconPicker: typeof import('./src/components/Controls/IconPicker.vue')['default']
|
||||
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
|
||||
InlineLessonMenu: typeof import('./src/components/Notes/InlineLessonMenu.vue')['default']
|
||||
@@ -82,13 +73,13 @@ declare module 'vue' {
|
||||
LessonContent: typeof import('./src/components/LessonContent.vue')['default']
|
||||
LessonHelp: typeof import('./src/components/LessonHelp.vue')['default']
|
||||
Link: typeof import('./src/components/Controls/Link.vue')['default']
|
||||
LiveClass: typeof import('./src/components/LiveClass.vue')['default']
|
||||
LiveClassAttendance: typeof import('./src/components/Modals/LiveClassAttendance.vue')['default']
|
||||
LiveClassModal: typeof import('./src/components/Modals/LiveClassModal.vue')['default']
|
||||
LMSLogo: typeof import('./src/components/Icons/LMSLogo.vue')['default']
|
||||
Members: typeof import('./src/components/Settings/Members.vue')['default']
|
||||
MobileLayout: typeof import('./src/components/MobileLayout.vue')['default']
|
||||
MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default']
|
||||
NewMemberModal: typeof import('./src/components/Modals/NewMemberModal.vue')['default']
|
||||
NoPermission: typeof import('./src/components/NoPermission.vue')['default']
|
||||
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
|
||||
Notes: typeof import('./src/components/Notes/Notes.vue')['default']
|
||||
|
||||
+15
-15
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="{{ boot.lang }}" dir="{{ boot.text_direction }}">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="{{ favicon }}" />
|
||||
@@ -201,26 +201,26 @@
|
||||
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{ title }}</title>
|
||||
<meta name="title" content="{{ meta.title }}" />
|
||||
<meta name="image" content="{{ meta.image }}" />
|
||||
<meta name="description" content="{{ meta.description }}" />
|
||||
<meta name="keywords" content="{{ meta.keywords }}" />
|
||||
<meta property="og:title" content="{{ meta.title }}" />
|
||||
<meta property="og:image" content="{{ meta.image }}" />
|
||||
<meta property="og:description" content="{{ meta.description }}" />
|
||||
<meta name="twitter:title" content="{{ meta.title }}" />
|
||||
<meta name="twitter:image" content="{{ meta.image }}" />
|
||||
<meta name="twitter:description" content="{{ meta.description }}" />
|
||||
<title>{{ title | e }}</title>
|
||||
<meta name="title" content="{{ meta.title | e }}" />
|
||||
<meta name="image" content="{{ meta.image | e }}" />
|
||||
<meta name="description" content="{{ meta.description | e }}" />
|
||||
<meta name="keywords" content="{{ meta.keywords | e }}" />
|
||||
<meta property="og:title" content="{{ meta.title | e }}" />
|
||||
<meta property="og:image" content="{{ meta.image | e }}" />
|
||||
<meta property="og:description" content="{{ meta.description | e }}" />
|
||||
<meta name="twitter:title" content="{{ meta.title | e }}" />
|
||||
<meta name="twitter:image" content="{{ meta.image | e }}" />
|
||||
<meta name="twitter:description" content="{{ meta.description | e }}" />
|
||||
</head>
|
||||
<body class="sm:overscroll-y-none no-scrollbar">
|
||||
<div id="app">
|
||||
<div id="seo-content">
|
||||
<h1>{{ meta.title }}</h1>
|
||||
<h1>{{ meta.title | e }}</h1>
|
||||
<p>
|
||||
{{ meta.description }}
|
||||
{{ meta.description | e }}
|
||||
</p>
|
||||
<a href="{{ meta.link }}">Know More</a>
|
||||
<a href="{{ meta.link | e }}">Know More</a>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
|
||||
@@ -27,13 +27,11 @@
|
||||
"@editorjs/table": "2.4.2",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"ace-builds": "1.36.2",
|
||||
"apexcharts": "4.3.0",
|
||||
"chart.js": "4.4.1",
|
||||
"codemirror": "6.0.1",
|
||||
"dayjs": "1.11.10",
|
||||
"dompurify": "3.2.6",
|
||||
"feather-icons": "4.28.0",
|
||||
"frappe-ui": "^0.1.261",
|
||||
"frappe-ui": "^0.1.276",
|
||||
"highlight.js": "11.11.1",
|
||||
"lucide-vue-next": "0.383.0",
|
||||
"markdown-it": "14.0.0",
|
||||
@@ -57,6 +55,6 @@
|
||||
"tailwindcss": "^3.4.15",
|
||||
"unplugin-auto-import": "^20.3.0",
|
||||
"vite": "5.0.11",
|
||||
"vite-plugin-pwa": "0.15.0"
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<FrappeUIProvider>
|
||||
<Layout class="isolate text-base">
|
||||
<Layout class="isolate text-p-base">
|
||||
<router-view />
|
||||
</Layout>
|
||||
<InstallPrompt v-if="isMobile && !settings.data?.disable_pwa" />
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
<template>
|
||||
<div v-if="batch?.data" class="">
|
||||
<div class="w-full flex items-center justify-between pb-4">
|
||||
<div class="font-medium text-ink-gray-7">
|
||||
{{ __('Statistics') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-5 mb-8">
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{ title: __('Students'), value: studentCount.data || 0 }"
|
||||
/>
|
||||
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: __('Certified'),
|
||||
value: certificationCount.data || 0,
|
||||
}"
|
||||
/>
|
||||
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: __('Courses'),
|
||||
value: batch?.data?.courses?.length || 0,
|
||||
}"
|
||||
/>
|
||||
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{ title: __('Assessments'), value: assessmentCount.data || 0 }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AxisChart
|
||||
v-if="showProgressChart"
|
||||
class="border rounded-lg p-3 min-h-[300px]"
|
||||
:config="{
|
||||
data: filteredChartData,
|
||||
title: __('Batch Summary'),
|
||||
subtitle: __('Progress of students in courses and assessments'),
|
||||
xAxis: {
|
||||
key: 'task',
|
||||
title: 'Tasks',
|
||||
type: 'category',
|
||||
},
|
||||
yAxis: {
|
||||
title: __('Number of Students'),
|
||||
echartOptions: {
|
||||
minInterval: 1,
|
||||
},
|
||||
},
|
||||
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>
|
||||
@@ -1,53 +0,0 @@
|
||||
<template>
|
||||
<div v-if="communications.data?.length">
|
||||
<div v-for="comm in communications.data">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center">
|
||||
<Avatar :label="comm.sender_full_name" size="lg" />
|
||||
<div class="ml-2 text-ink-gray-7">
|
||||
{{ comm.sender_full_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
{{ timeAgo(comm.communication_date) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="prose prose-sm bg-surface-menu-bar !min-w-full px-4 py-2 rounded-md"
|
||||
v-html="comm.content"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-ink-gray-5">
|
||||
{{ __('No announcements') }}
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, Avatar } from 'frappe-ui'
|
||||
import { timeAgo } from '@/utils'
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const communications = createResource({
|
||||
url: 'lms.lms.api.get_announcements',
|
||||
makeParams(value) {
|
||||
return {
|
||||
batch: props.batch,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
cache: ['announcement', props.batch],
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.prose-sm p {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -49,8 +49,9 @@
|
||||
:label="__('Select an Assignment')"
|
||||
:onCreate="(value, close) => redirectToForm()"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
<Switch
|
||||
size="sm"
|
||||
:description="__('Only show assignments from the current course')"
|
||||
:label="__('Filter assignments by course')"
|
||||
v-model="filterAssignmentsByCourse"
|
||||
/>
|
||||
@@ -61,11 +62,11 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, FormControl } from 'frappe-ui'
|
||||
import { Dialog, Switch } from 'frappe-ui'
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Link } from 'frappe-ui/frappe'
|
||||
import { getLmsRoute } from '@/utils/basePath'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = ref(false)
|
||||
const quiz = ref(null)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
:class="{ 'border rounded-lg overflow-auto': !showTitle }"
|
||||
>
|
||||
<div
|
||||
class="border-r p-5 overflow-y-auto h-[calc(100vh-3.2rem)]"
|
||||
class="border-e p-5 overflow-y-auto h-[calc(100vh-3.2rem)]"
|
||||
:class="{ 'h-full': !showTitle }"
|
||||
>
|
||||
<div v-if="showTitle" class="text-lg font-semibold mb-5 text-ink-gray-9">
|
||||
@@ -16,8 +16,8 @@
|
||||
{{ __('Submission by') }} {{ submissionResource.doc?.member_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-7 font-medium mb-2">
|
||||
{{ __('Question') }}:
|
||||
<div class="text-ink-gray-9 font-semibold mb-5">
|
||||
{{ __('Assignment') }}: {{ assignment.data.title }}
|
||||
</div>
|
||||
<div
|
||||
v-html="assignment.data.question"
|
||||
@@ -31,7 +31,7 @@
|
||||
<div class="font-semibold text-ink-gray-9">
|
||||
{{ __('Submission') }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Badge v-if="isDirty" theme="orange">
|
||||
{{ __('Not Saved') }}
|
||||
</Badge>
|
||||
@@ -42,7 +42,11 @@
|
||||
>
|
||||
{{ submissionResource.doc?.status }}
|
||||
</Badge>
|
||||
<Button variant="solid" @click="submitAssignment()">
|
||||
<Button
|
||||
v-if="canModifyAssignment || canGradeSubmission"
|
||||
variant="solid"
|
||||
@click="submitAssignment()"
|
||||
>
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -73,12 +77,15 @@
|
||||
}}
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!submissionResource.doc?.assignment_attachment"
|
||||
v-if="!attachment"
|
||||
:fileTypes="getType()"
|
||||
:uploadArgs="{
|
||||
private: true,
|
||||
}"
|
||||
:validateFile="validateFile"
|
||||
:validateFile="
|
||||
(file) =>
|
||||
validateFile(file, true, assignment.data.type.toLowerCase())
|
||||
"
|
||||
@success="(file) => saveSubmission(file)"
|
||||
>
|
||||
<template #default="{ uploading, progress, openFileSelector }">
|
||||
@@ -94,27 +101,23 @@
|
||||
<div v-else>
|
||||
<div class="flex items-center text-ink-gray-7">
|
||||
<a
|
||||
:href="submissionResource.doc.assignment_attachment"
|
||||
:href="attachment"
|
||||
target="_blank"
|
||||
class="cursor-pointer !no-underline text-sm leading-5"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<div class="border rounded-md p-2 me-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5" />
|
||||
</div>
|
||||
<span>
|
||||
{{
|
||||
submissionResource.doc.assignment_attachment
|
||||
.split('/')
|
||||
.pop()
|
||||
}}
|
||||
{{ attachment.split('/').pop() }}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
<X
|
||||
v-if="canModifyAssignment"
|
||||
@click="removeSubmission()"
|
||||
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ms-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,6 +141,7 @@
|
||||
@change="(val) => (answer = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
:readonly="!canModifyAssignment"
|
||||
:uploadArgs="{
|
||||
private: true,
|
||||
}"
|
||||
@@ -150,7 +154,7 @@
|
||||
user.data?.name == submissionResource.doc?.owner &&
|
||||
submissionResource.doc?.comments
|
||||
"
|
||||
class="mt-8 p-3 border rounded-lg"
|
||||
class="mt-8 p-3 border rounded-lg bg-surface-gray-2"
|
||||
>
|
||||
<div class="text-ink-gray-5 mb-4">
|
||||
{{ __('Comments by Evaluator') }}
|
||||
@@ -213,8 +217,10 @@ import {
|
||||
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { validateFile } from '@/utils'
|
||||
|
||||
const answer = ref(null)
|
||||
const attachment = ref(null)
|
||||
const comments = ref(null)
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
@@ -264,118 +270,104 @@ const assignment = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const newSubmission = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
let doc = {
|
||||
doctype: 'LMS Assignment Submission',
|
||||
assignment: props.assignmentID,
|
||||
member: user.data?.name,
|
||||
}
|
||||
if (!showUploader()) {
|
||||
doc.answer = answer.value
|
||||
}
|
||||
return {
|
||||
doc: doc,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const submissionResource = createDocumentResource({
|
||||
doctype: 'LMS Assignment Submission',
|
||||
name: props.submissionName,
|
||||
auto: false,
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
auto: false,
|
||||
cache: [user.data?.name, props.assignmentID],
|
||||
})
|
||||
|
||||
watch(submissionResource, () => {
|
||||
if (submissionResource.doc) {
|
||||
if (submissionResource.doc.answer) {
|
||||
answer.value = submissionResource.doc.answer
|
||||
}
|
||||
if (submissionResource.doc.comments) {
|
||||
comments.value = submissionResource.doc.comments
|
||||
}
|
||||
if (submissionResource.isDirty) {
|
||||
isDirty.value = true
|
||||
} else if (
|
||||
showUploader() &&
|
||||
!submissionResource.doc.assignment_attachment
|
||||
) {
|
||||
isDirty.value = true
|
||||
} else if (!showUploader() && !answer.value) {
|
||||
isDirty.value = true
|
||||
} else {
|
||||
isDirty.value = false
|
||||
}
|
||||
if (!submissionResource.doc) return
|
||||
if (submissionResource.doc.answer) {
|
||||
answer.value = submissionResource.doc.answer
|
||||
}
|
||||
if (submissionResource.doc.assignment_attachment) {
|
||||
attachment.value = submissionResource.doc.assignment_attachment
|
||||
}
|
||||
if (submissionResource.doc.comments) {
|
||||
comments.value = submissionResource.doc.comments
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => submissionResource.doc,
|
||||
() => {
|
||||
if (
|
||||
props.submissionName == 'new' &&
|
||||
submissionResource.doc?.assignment_attachment
|
||||
) {
|
||||
isDirty.value = true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const submitAssignment = () => {
|
||||
if (props.submissionName != 'new') {
|
||||
let evaluator =
|
||||
submissionResource.doc && submissionResource.doc.owner != user.data?.name
|
||||
? user.data?.name
|
||||
: null
|
||||
|
||||
submissionResource.setValue.submit(
|
||||
{
|
||||
...submissionResource.doc,
|
||||
evaluator: evaluator,
|
||||
comments: comments.value,
|
||||
answer: answer.value,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
isDirty.value = false
|
||||
toast.success(__('Changes saved successfully'))
|
||||
},
|
||||
}
|
||||
)
|
||||
updateSubmission()
|
||||
} else {
|
||||
addNewSubmission()
|
||||
}
|
||||
}
|
||||
|
||||
const prepareSubmissionDoc = () => {
|
||||
let doc = {
|
||||
doctype: 'LMS Assignment Submission',
|
||||
assignment: props.assignmentID,
|
||||
member: user.data?.name,
|
||||
}
|
||||
if (!showUploader()) {
|
||||
doc.answer = answer.value
|
||||
} else {
|
||||
doc.assignment_attachment = attachment.value
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
const addNewSubmission = () => {
|
||||
newSubmission.submit(
|
||||
{},
|
||||
let doc = prepareSubmissionDoc()
|
||||
if (!doc.assignment_attachment && !doc.answer) {
|
||||
toast.error(
|
||||
__('Please provide an answer or upload a file before submitting.')
|
||||
)
|
||||
return
|
||||
}
|
||||
call('frappe.client.insert', {
|
||||
doc: doc,
|
||||
})
|
||||
.then((data) => {
|
||||
toast.success(__('Assignment submitted successfully'))
|
||||
router.push({
|
||||
name: 'AssignmentSubmission',
|
||||
params: {
|
||||
assignmentID: props.assignmentID,
|
||||
submissionName: data.name,
|
||||
},
|
||||
query: { fromLesson: router.currentRoute.value.query.fromLesson },
|
||||
})
|
||||
markLessonProgress()
|
||||
isDirty.value = false
|
||||
submissionResource.name = data.name
|
||||
submissionResource.reload()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
const updateSubmission = () => {
|
||||
let evaluator =
|
||||
submissionResource.doc && submissionResource.doc.owner != user.data?.name
|
||||
? user.data?.name
|
||||
: null
|
||||
|
||||
submissionResource.setValue.submit(
|
||||
{
|
||||
...submissionResource.doc,
|
||||
evaluator: evaluator,
|
||||
comments: comments.value,
|
||||
answer: answer.value,
|
||||
assignment_attachment: attachment.value,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
toast.success(__('Assignment submitted successfully'))
|
||||
if (router.currentRoute.value.name == 'AssignmentSubmission') {
|
||||
router.push({
|
||||
name: 'AssignmentSubmission',
|
||||
params: {
|
||||
assignmentID: props.assignmentID,
|
||||
submissionName: data.name,
|
||||
},
|
||||
query: { fromLesson: router.currentRoute.value.query.fromLesson },
|
||||
})
|
||||
} else {
|
||||
markLessonProgress()
|
||||
router.go()
|
||||
}
|
||||
submissionResource.name = data.name
|
||||
submissionResource.reload()
|
||||
isDirty.value = false
|
||||
toast.success(__('Changes saved successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -383,19 +375,21 @@ const addNewSubmission = () => {
|
||||
|
||||
const saveSubmission = (file) => {
|
||||
isDirty.value = true
|
||||
submissionResource.doc.assignment_attachment = file.file_url
|
||||
attachment.value = file.file_url
|
||||
}
|
||||
|
||||
const markLessonProgress = () => {
|
||||
if (router.currentRoute.value.name == 'Lesson') {
|
||||
let courseName = router.currentRoute.value.params.courseName
|
||||
let chapterNumber = router.currentRoute.value.params.chapterNumber
|
||||
let lessonNumber = router.currentRoute.value.params.lessonNumber
|
||||
let pathname = window.location.pathname.split('/')
|
||||
if (!pathname.includes('courses'))
|
||||
pathname = window.parent.location.pathname.split('/')
|
||||
if (pathname[2] != 'courses') return
|
||||
let lessonIndex = pathname.pop().split('-')
|
||||
|
||||
if (lessonIndex.length == 2) {
|
||||
call('lms.lms.api.mark_lesson_progress', {
|
||||
course: courseName,
|
||||
chapter_number: chapterNumber,
|
||||
lesson_number: lessonNumber,
|
||||
course: pathname[3],
|
||||
chapter_number: lessonIndex[0],
|
||||
lesson_number: lessonIndex[1],
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -417,24 +411,9 @@ const getType = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const validateFile = (file) => {
|
||||
let type = assignment.data?.type
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (type == 'Image' && !['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||
return 'Only image file is allowed.'
|
||||
} else if (
|
||||
type == 'Document' &&
|
||||
!['doc', 'docx', 'xml'].includes(extension)
|
||||
) {
|
||||
return 'Only document file is allowed.'
|
||||
} else if (type == 'PDF' && !['pdf'].includes(extension)) {
|
||||
return 'Only PDF file is allowed.'
|
||||
}
|
||||
}
|
||||
|
||||
const removeSubmission = () => {
|
||||
isDirty.value = true
|
||||
submissionResource.doc.assignment_attachment = ''
|
||||
attachment.value = null
|
||||
}
|
||||
|
||||
const canGradeSubmission = computed(() => {
|
||||
@@ -448,11 +427,15 @@ const canGradeSubmission = computed(() => {
|
||||
})
|
||||
|
||||
const canModifyAssignment = computed(() => {
|
||||
return (
|
||||
!submissionResource.doc ||
|
||||
(submissionResource.doc?.owner == user.data?.name &&
|
||||
submissionResource.doc?.status == 'Not Graded')
|
||||
)
|
||||
if (props.submissionName == 'new') {
|
||||
return true
|
||||
} else if (
|
||||
submissionResource.doc?.owner == user.data?.name &&
|
||||
submissionResource.doc?.status == 'Not Graded'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const submissionStatusOptions = computed(() => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<audio @ended="handleAudioEnd" controlsList="nodownload" class="mb-4">
|
||||
<source :src="encodeURI(file)" type="audio/mp3" />
|
||||
</audio>
|
||||
<div class="flex items-center space-x-2 shadow rounded-lg p-1 w-1/2">
|
||||
<div class="flex items-center gap-x-2 shadow rounded-lg p-1 w-1/2">
|
||||
<Button variant="ghost" @click="togglePlay">
|
||||
<template #icon>
|
||||
<Play v-if="!isPlaying" class="w-4 h-4 text-ink-gray-9" />
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-10">
|
||||
<UpcomingEvaluations
|
||||
:batch="batch.data.name"
|
||||
:endDate="batch.data.evaluation_end_date"
|
||||
:courses="batch.data.courses"
|
||||
/>
|
||||
<Assessments :batch="batch.data.name" />
|
||||
<!-- <StudentHeatmap /> -->
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||
import Assessments from '@/components/Assessments.vue'
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
isStudent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,227 +0,0 @@
|
||||
<template>
|
||||
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72">
|
||||
<Badge
|
||||
v-if="batch.data.seat_count && batch.data.seats_left > 0"
|
||||
variant="subtle"
|
||||
theme="green"
|
||||
size="md"
|
||||
:class="
|
||||
batch.data.amount || batch.data.courses.length
|
||||
? 'float-right'
|
||||
: 'w-fit mb-4'
|
||||
"
|
||||
:label="
|
||||
batch.data.seats_left +
|
||||
' ' +
|
||||
(batch.data.seats_left > 1 ? __('Seats Left') : __('Seat Left'))
|
||||
"
|
||||
/>
|
||||
<Badge
|
||||
v-else-if="batch.data.seat_count && batch.data.seats_left <= 0"
|
||||
variant="subtle"
|
||||
theme="red"
|
||||
size="md"
|
||||
class="float-right"
|
||||
:label="__('Sold Out')"
|
||||
/>
|
||||
<div
|
||||
v-if="batch.data.amount"
|
||||
class="text-lg font-semibold mb-3 text-ink-gray-9"
|
||||
>
|
||||
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="batch.data.courses.length"
|
||||
class="flex items-center mb-3 text-ink-gray-7"
|
||||
>
|
||||
<BookOpen class="h-4 w-4 stroke-1.5 mr-2" />
|
||||
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
|
||||
</div>
|
||||
<DateRange
|
||||
:startDate="batch.data.start_date"
|
||||
:endDate="batch.data.end_date"
|
||||
class="mb-3"
|
||||
/>
|
||||
<div class="flex items-center mb-3 text-ink-gray-7">
|
||||
<Clock class="h-4 w-4 stroke-1.5 mr-2" />
|
||||
<span>
|
||||
{{ formatTime(batch.data.start_time) }} -
|
||||
{{ formatTime(batch.data.end_time) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="batch.data.timezone" class="flex items-center text-ink-gray-7">
|
||||
<Globe class="h-4 w-4 stroke-1.5 mr-2" />
|
||||
<span>
|
||||
{{ batch.data.timezone }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!readOnlyMode">
|
||||
<router-link
|
||||
v-if="canAccessBatch"
|
||||
:to="{
|
||||
name: 'Batch',
|
||||
params: {
|
||||
batchName: batch.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" class="w-full mt-4">
|
||||
<template #prefix>
|
||||
<LogIn v-if="isStudent" class="size-4 stroke-1.5" />
|
||||
<Settings v-else class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ isStudent ? __('Visit Batch') : __('Manage Batch') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Billing',
|
||||
params: {
|
||||
type: 'batch',
|
||||
name: batch.data.name,
|
||||
},
|
||||
}"
|
||||
v-else-if="
|
||||
batch.data.paid_batch &&
|
||||
batch.data.seats_left > 0 &&
|
||||
batch.data.accept_enrollments
|
||||
"
|
||||
>
|
||||
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
|
||||
<template #prefix>
|
||||
<CreditCard class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Register Now') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button
|
||||
variant="solid"
|
||||
class="w-full mt-2"
|
||||
v-else-if="
|
||||
batch.data.allow_self_enrollment &&
|
||||
batch.data.seats_left &&
|
||||
batch.data.accept_enrollments
|
||||
"
|
||||
@click="enrollInBatch()"
|
||||
>
|
||||
<template #prefix>
|
||||
<GraduationCap class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Enroll Now') }}
|
||||
</Button>
|
||||
<router-link
|
||||
v-if="canEditBatch"
|
||||
:to="{
|
||||
name: 'BatchForm',
|
||||
params: {
|
||||
batchName: batch.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button class="w-full mt-2">
|
||||
<template #prefix>
|
||||
<Pencil class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Edit') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { inject, computed } from 'vue'
|
||||
import { Badge, Button, createResource, toast } from 'frappe-ui'
|
||||
import {
|
||||
BookOpen,
|
||||
Clock,
|
||||
CreditCard,
|
||||
Globe,
|
||||
GraduationCap,
|
||||
LogIn,
|
||||
Pencil,
|
||||
Settings,
|
||||
} from 'lucide-vue-next'
|
||||
import { formatNumberIntoCurrency, formatTime } from '@/utils'
|
||||
import DateRange from '@/components/Common/DateRange.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const enroll = createResource({
|
||||
url: 'lms.lms.utils.enroll_in_batch',
|
||||
makeParams(values) {
|
||||
return {
|
||||
batch: props.batch.data.name,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const enrollInBatch = () => {
|
||||
if (!user.data) {
|
||||
window.location.href = `/login?redirect-to=/batches/details/${props.batch.data.name}`
|
||||
}
|
||||
enroll.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
toast.success(__('You have been enrolled in this batch'))
|
||||
router.push({
|
||||
name: 'Batch',
|
||||
params: {
|
||||
batchName: props.batch.data.name,
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const isStudent = computed(() => {
|
||||
return user.data
|
||||
? props.batch.data?.students?.includes(user.data?.name)
|
||||
: false
|
||||
})
|
||||
|
||||
const isModerator = computed(() => {
|
||||
return user.data?.is_moderator
|
||||
})
|
||||
|
||||
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,226 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-ink-gray-9 font-medium">
|
||||
{{ studentCount.data ?? 0 }} {{ __('Students') }}
|
||||
</div>
|
||||
<Button v-if="!readOnlyMode" @click="openStudentModal()">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="students.data?.length">
|
||||
<ListView
|
||||
class="max-h-[75vh]"
|
||||
:columns="studentColumns"
|
||||
:rows="students.data"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem
|
||||
:item="item"
|
||||
v-for="item in studentColumns"
|
||||
:title="item.label"
|
||||
>
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon
|
||||
v-if="item.icon"
|
||||
:name="item.icon"
|
||||
class="h-4 w-4 stroke-1.5"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow
|
||||
:row="row"
|
||||
v-for="row in students.data"
|
||||
class="group cursor-pointer hover:bg-surface-gray-2 rounded"
|
||||
@click="openStudentProgressModal(row)"
|
||||
>
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem
|
||||
:item="row[column.key]"
|
||||
:align="column.align"
|
||||
class="text-sm"
|
||||
>
|
||||
<template #prefix>
|
||||
<div v-if="column.key == 'full_name'">
|
||||
<Avatar
|
||||
class="flex items-center"
|
||||
:image="row['user_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="column.key == 'progress'"
|
||||
class="flex items-center space-x-4 w-full"
|
||||
>
|
||||
<ProgressBar :progress="row[column.key]" size="sm" />
|
||||
<div class="text-xs">{{ row[column.key] }}%</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="removeStudents(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
<div class="mt-4 flex justify-center" v-if="students.hasNextPage">
|
||||
<Button @click="students.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</ListView>
|
||||
</div>
|
||||
<div v-else-if="!students.loading" class="text-sm italic text-ink-gray-5">
|
||||
{{ __('There are no students in this batch.') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StudentModal
|
||||
:batch="props.batch.data.name"
|
||||
v-model="showStudentModal"
|
||||
v-model:reloadStudents="students"
|
||||
v-model:batchModal="props.batch"
|
||||
/>
|
||||
<BatchStudentProgress
|
||||
:student="selectedStudent"
|
||||
v-model="showStudentProgressModal"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
createListResource,
|
||||
createResource,
|
||||
FeatherIcon,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListSelectBanner,
|
||||
ListRow,
|
||||
ListRows,
|
||||
ListView,
|
||||
ListRowItem,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
import StudentModal from '@/components/Modals/StudentModal.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
|
||||
|
||||
const showStudentModal = ref(false)
|
||||
const showStudentProgressModal = ref(false)
|
||||
const selectedStudent = ref(null)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const studentCount = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
cache: ['batch_student_count', props.batch?.data?.name],
|
||||
params: {
|
||||
doctype: 'LMS Batch Enrollment',
|
||||
filters: { batch: props.batch?.data?.name },
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const students = createListResource({
|
||||
doctype: 'LMS Batch Enrollment',
|
||||
url: 'lms.lms.utils.get_batch_students',
|
||||
cache: ['batch_students', props.batch?.data?.name],
|
||||
pageLength: 50,
|
||||
filters: {
|
||||
batch: props.batch?.data?.name,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const studentColumns = [
|
||||
{
|
||||
label: 'Full Name',
|
||||
key: 'full_name',
|
||||
width: '25rem',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
label: 'Progress',
|
||||
key: 'progress',
|
||||
width: '15rem',
|
||||
icon: 'activity',
|
||||
},
|
||||
{
|
||||
label: 'Last Active',
|
||||
key: 'last_active',
|
||||
width: '10rem',
|
||||
align: 'center',
|
||||
icon: 'clock',
|
||||
},
|
||||
]
|
||||
|
||||
const openStudentModal = () => {
|
||||
showStudentModal.value = true
|
||||
}
|
||||
|
||||
const openStudentProgressModal = (row) => {
|
||||
showStudentProgressModal.value = true
|
||||
selectedStudent.value = row
|
||||
}
|
||||
|
||||
const deleteStudents = createResource({
|
||||
url: 'lms.lms.api.delete_documents',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Batch Enrollment',
|
||||
documents: values.students,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const removeStudents = (selections, unselectAll) => {
|
||||
deleteStudents.submit(
|
||||
{
|
||||
students: Array.from(selections),
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
students.reload()
|
||||
studentCount.reload()
|
||||
props.batch.reload()
|
||||
toast.success(__('Students deleted successfully'))
|
||||
unselectAll()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -2,13 +2,13 @@
|
||||
<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">
|
||||
<div class="flex items-center gap-x-2 ps-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"
|
||||
class="w-full border-none bg-transparent py-3 !ps-2 pe-4.5 text-base text-ink-gray-7 placeholder-ink-gray-4 focus:ring-0"
|
||||
@input="onInput"
|
||||
v-model="query"
|
||||
autocomplete="off"
|
||||
@@ -32,9 +32,9 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center space-x-5 w-full border-t py-2 text-sm text-ink-gray-7 px-4.5"
|
||||
class="flex items-center gap-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">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<MoveUp
|
||||
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
|
||||
/>
|
||||
@@ -45,7 +45,7 @@
|
||||
{{ __('to navigate') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<CornerDownLeft
|
||||
class="size-5 stroke-1.5 bg-surface-gray-2 p-1 rounded-sm"
|
||||
/>
|
||||
@@ -53,7 +53,7 @@
|
||||
{{ __('to select') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<span class="bg-surface-gray-2 p-1 rounded-sm"> esc </span>
|
||||
<span>
|
||||
{{ __('to close') }}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
:class="{ 'bg-surface-gray-2': item.isActive }"
|
||||
@click="emit('navigateTo', item.route)"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex items-center text-ink-gray-7">
|
||||
<Calendar class="h-4 w-4 stroke-1.5 mr-2" />
|
||||
<Calendar class="h-4 w-4 stroke-1.5 me-2" />
|
||||
<span>
|
||||
{{ getFormattedDateRange(props.startDate, props.endDate) }}
|
||||
</span>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="pb-5 float-right">
|
||||
<div class="pb-5 float-end">
|
||||
<Button variant="solid" @click="sendMail(close)">
|
||||
{{ __('Send') }}
|
||||
</Button>
|
||||
|
||||
@@ -1,95 +1,151 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Label -->
|
||||
<div v-if="label" class="text-xs text-ink-gray-5 mb-1">
|
||||
{{ __(label) }}
|
||||
<span class="text-ink-red-3" v-if="attrs.required">*</span>
|
||||
</div>
|
||||
<Combobox v-model="selectedValue" nullable v-slot="{ open }">
|
||||
<div class="relative w-full">
|
||||
<ComboboxInput
|
||||
class="form-input w-full"
|
||||
:class="inputClasses"
|
||||
type="text"
|
||||
:value="selectedValue"
|
||||
autocomplete="off"
|
||||
@click="onFocus"
|
||||
/>
|
||||
<ComboboxButton ref="trigger" class="hidden" />
|
||||
|
||||
<!-- Dropdown -->
|
||||
<ComboboxOptions
|
||||
class="absolute z-20 mt-1 w-full rounded-lg bg-surface-modal py-1 text-base border-2 border-outline-gray-modals shadow-lg"
|
||||
>
|
||||
<input
|
||||
ref="search"
|
||||
v-model="query"
|
||||
class="form-input w-[98%] rounded-tl-lg rounded-tr-lg mb-1 mx-1"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<!-- Options -->
|
||||
<div class="my-1 max-h-[12rem] overflow-y-auto px-1.5">
|
||||
<template v-for="group in groups" :key="group.key">
|
||||
<div
|
||||
v-if="group.group && !group.hideLabel"
|
||||
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
|
||||
<Combobox
|
||||
v-model="selectedValue"
|
||||
nullable
|
||||
v-slot="{ open: isComboboxOpen }"
|
||||
>
|
||||
<Popover
|
||||
class="w-full"
|
||||
v-model:show="showOptions"
|
||||
:matchTargetWidth="true"
|
||||
>
|
||||
<template #target="{ open: openPopover, togglePopover }">
|
||||
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
||||
<div class="w-full">
|
||||
<button
|
||||
class="flex w-full items-center justify-between focus:outline-none"
|
||||
:class="inputClasses"
|
||||
@click="
|
||||
() => {
|
||||
showOptions = !showOptions
|
||||
togglePopover()
|
||||
}
|
||||
"
|
||||
:disabled="attrs.readonly"
|
||||
>
|
||||
{{ group.group }}
|
||||
</div>
|
||||
|
||||
<ComboboxOption
|
||||
v-for="option in group.items"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
v-slot="{ active }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex items-center rounded px-2.5 py-2 text-base cursor-pointer',
|
||||
{ 'bg-surface-gray-2': active },
|
||||
]"
|
||||
>
|
||||
<div class="flex flex-col gap-1 p-1">
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
{{
|
||||
option.value === option.label
|
||||
? option.description
|
||||
: option.label
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center w-[90%]">
|
||||
<slot name="prefix" />
|
||||
<span
|
||||
class="block truncate text-base leading-5"
|
||||
v-if="selectedValue"
|
||||
>
|
||||
{{ displayValue(selectedValue) }}
|
||||
</span>
|
||||
<span class="text-base leading-5 text-ink-gray-4" v-else>
|
||||
{{ placeholder || '' }}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown class="h-4 w-4 stroke-1.5" />
|
||||
</button>
|
||||
</div>
|
||||
</slot>
|
||||
</template>
|
||||
<template #body="{ isOpen }">
|
||||
<div v-show="isOpen" class="">
|
||||
<div
|
||||
v-if="groups.length === 0"
|
||||
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
|
||||
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
|
||||
>
|
||||
{{ __('No results found') }}
|
||||
<div class="relative px-1.5 pt-0.5">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="form-input w-full"
|
||||
type="text"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value
|
||||
}
|
||||
"
|
||||
:value="query"
|
||||
autocomplete="off"
|
||||
placeholder="Search"
|
||||
/>
|
||||
<button
|
||||
class="absolute end-1.5 inline-flex h-7 w-7 items-center justify-center"
|
||||
@click="selectedValue = null"
|
||||
>
|
||||
<X class="h-4 w-4 stroke-1.5 text-ink-gray-7" />
|
||||
</button>
|
||||
</div>
|
||||
<ComboboxOptions
|
||||
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
|
||||
static
|
||||
>
|
||||
<div
|
||||
class="mt-1.5"
|
||||
v-for="group in groups"
|
||||
:key="group.key"
|
||||
v-show="group.items.length > 0"
|
||||
>
|
||||
<div
|
||||
v-if="group.group && !group.hideLabel"
|
||||
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
|
||||
>
|
||||
{{ group.group }}
|
||||
</div>
|
||||
<ComboboxOption
|
||||
as="template"
|
||||
v-for="option in group.items"
|
||||
:key="option.value"
|
||||
:value="option"
|
||||
v-slot="{ active, selected }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex items-center rounded px-2.5 text-base py-1.5',
|
||||
optionLines(option).secondary ? '' : 'h-7',
|
||||
{ 'bg-surface-gray-2': active },
|
||||
]"
|
||||
>
|
||||
<slot
|
||||
name="item-prefix"
|
||||
v-bind="{ active, selected, option }"
|
||||
/>
|
||||
<slot
|
||||
name="item-label"
|
||||
v-bind="{ active, selected, option }"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col px-1"
|
||||
:class="
|
||||
optionLines(option).secondary ? 'gap-0.5' : ''
|
||||
"
|
||||
>
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
{{ optionLines(option).primary }}
|
||||
</div>
|
||||
<div
|
||||
v-if="optionLines(option).secondary"
|
||||
class="text-sm text-ink-gray-5"
|
||||
>
|
||||
{{ optionLines(option).secondary }}
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</div>
|
||||
<li
|
||||
v-if="groups.length == 0"
|
||||
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
|
||||
>
|
||||
{{ __('No results found') }}
|
||||
</li>
|
||||
</ComboboxOptions>
|
||||
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
|
||||
<slot
|
||||
name="footer"
|
||||
v-bind="{ value: search?.el._value, close }"
|
||||
></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
v-if="slots.footer"
|
||||
class="border-t border-outline-gray-modals p-1.5 pb-0.5"
|
||||
>
|
||||
<slot
|
||||
name="footer"
|
||||
v-bind="{
|
||||
value: selectedValue,
|
||||
close,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Combobox>
|
||||
</div>
|
||||
</template>
|
||||
@@ -100,15 +156,15 @@ import {
|
||||
ComboboxInput,
|
||||
ComboboxOptions,
|
||||
ComboboxOption,
|
||||
ComboboxButton,
|
||||
} from '@headlessui/vue'
|
||||
import { Popover } from 'frappe-ui'
|
||||
import { ChevronDown, X } from 'lucide-vue-next'
|
||||
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Object],
|
||||
default: null,
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
@@ -139,97 +195,122 @@ const props = defineProps({
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:query', 'change'])
|
||||
const trigger = ref(null)
|
||||
|
||||
const query = ref('')
|
||||
const showOptions = ref(false)
|
||||
const search = ref(null)
|
||||
|
||||
const attrs = useAttrs()
|
||||
const slots = useSlots()
|
||||
const selectedValue = ref(props.modelValue)
|
||||
const query = ref('')
|
||||
|
||||
const valuePropPassed = computed(() => 'value' in attrs)
|
||||
|
||||
watch(selectedValue, (val) => {
|
||||
query.value = ''
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
|
||||
const selectedValue = computed({
|
||||
get() {
|
||||
return valuePropPassed.value ? attrs.value : props.modelValue
|
||||
},
|
||||
set(val) {
|
||||
query.value = ''
|
||||
if (val) {
|
||||
showOptions.value = false
|
||||
}
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
|
||||
},
|
||||
})
|
||||
|
||||
function clearValue() {
|
||||
emit('update:modelValue', null)
|
||||
function close() {
|
||||
showOptions.value = false
|
||||
}
|
||||
|
||||
const groups = computed(() => {
|
||||
if (!props.options?.length) return []
|
||||
if (!props.options || props.options.length == 0) return []
|
||||
|
||||
const normalized = props.options[0]?.group
|
||||
let groups = props.options[0]?.group
|
||||
? props.options
|
||||
: [{ group: '', items: props.options }]
|
||||
return normalized
|
||||
.map((group, i) => ({
|
||||
key: i,
|
||||
group: group.group,
|
||||
hideLabel: group.hideLabel || false,
|
||||
items: props.filterable ? filterOptions(group.items) : group.items,
|
||||
}))
|
||||
|
||||
return groups
|
||||
.map((group, i) => {
|
||||
return {
|
||||
key: i,
|
||||
group: group.group,
|
||||
hideLabel: group.hideLabel || false,
|
||||
items: props.filterable ? filterOptions(group.items) : group.items,
|
||||
}
|
||||
})
|
||||
.filter((group) => group.items.length > 0)
|
||||
})
|
||||
|
||||
function filterOptions(options) {
|
||||
if (!query.value) return options
|
||||
const q = query.value.toLowerCase()
|
||||
return options.filter((option) =>
|
||||
[option.label, option.value]
|
||||
.filter(Boolean)
|
||||
.some((text) => text.toString().toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
query,
|
||||
(val) => {
|
||||
emit('update:query', val)
|
||||
},
|
||||
{ debounce: 300 }
|
||||
)
|
||||
|
||||
const onFocus = () => {
|
||||
trigger.value?.$el.click()
|
||||
nextTick(() => {
|
||||
search.value?.focus()
|
||||
if (!query.value) {
|
||||
return options
|
||||
}
|
||||
return options.filter((option) => {
|
||||
let searchTexts = [option.label, option.value]
|
||||
return searchTexts.some((text) =>
|
||||
(text || '').toString().toLowerCase().includes(query.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
selectedValue.value = null
|
||||
trigger.value?.$el.click()
|
||||
function optionLines(option) {
|
||||
const primary = option.label
|
||||
let secondary = null
|
||||
if (option.description && option.description !== primary) {
|
||||
secondary = option.description
|
||||
} else if (option.value && option.value !== primary) {
|
||||
secondary = option.value
|
||||
}
|
||||
return { primary, secondary }
|
||||
}
|
||||
|
||||
const textColor = computed(() =>
|
||||
props.disabled ? 'text-ink-gray-5' : 'text-ink-gray-8'
|
||||
)
|
||||
function displayValue(option) {
|
||||
if (typeof option === 'string') {
|
||||
let allOptions = groups.value.flatMap((group) => group.items)
|
||||
let selectedOption = allOptions.find((o) => o.value === option)
|
||||
return selectedOption?.label || option
|
||||
}
|
||||
return option?.label
|
||||
}
|
||||
|
||||
watch(query, (q) => {
|
||||
emit('update:query', q)
|
||||
})
|
||||
|
||||
watch(showOptions, (val) => {
|
||||
if (val) {
|
||||
nextTick(() => {
|
||||
search.value.el.focus()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const textColor = computed(() => {
|
||||
return props.disabled ? 'text-ink-gray-5' : 'text-ink-gray-8'
|
||||
})
|
||||
|
||||
const inputClasses = computed(() => {
|
||||
const sizeClasses = {
|
||||
let sizeClasses = {
|
||||
sm: 'text-base rounded h-7',
|
||||
md: 'text-base rounded h-8',
|
||||
lg: 'text-lg rounded-md h-10',
|
||||
xl: 'text-xl rounded-md h-10',
|
||||
}[props.size]
|
||||
|
||||
const paddingClasses = {
|
||||
let paddingClasses = {
|
||||
sm: 'py-1.5 px-2',
|
||||
md: 'py-1.5 px-2.5',
|
||||
lg: 'py-1.5 px-3',
|
||||
xl: 'py-1.5 px-3',
|
||||
}[props.size]
|
||||
|
||||
const variant = props.disabled ? 'disabled' : props.variant
|
||||
|
||||
const variantClasses = {
|
||||
let variant = props.disabled ? 'disabled' : props.variant
|
||||
let variantClasses = {
|
||||
subtle:
|
||||
'border border-outline-gray-modals bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
|
||||
'border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus-within:bg-surface-white focus-within:border-outline-gray-4 focus-within:shadow-sm focus-within:ring-0 focus-within:ring-2 focus-within:ring-outline-gray-3',
|
||||
outline:
|
||||
'border border-outline-gray-2 bg-surface-white placeholder-ink-gray-4 hover:border-outline-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
|
||||
'border border-outline-gray-2 bg-surface-white placeholder-ink-gray-4 hover:border-outline-gray-3 hover:shadow-sm focus-within:bg-surface-white focus-within:border-outline-gray-4 focus-within:shadow-sm focus-within:ring-0 focus-within:ring-2 focus-within:ring-outline-gray-3',
|
||||
disabled: [
|
||||
'border bg-surface-menu-bar placeholder-ink-gray-3',
|
||||
props.variant === 'outline'
|
||||
@@ -246,4 +327,6 @@ const inputClasses = computed(() => {
|
||||
'transition-colors w-full',
|
||||
]
|
||||
})
|
||||
|
||||
defineExpose({ query })
|
||||
</script>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="overflow-visible border border-outline-gray-modals rounded-md">
|
||||
<div class="overflow-x-auto">
|
||||
<div
|
||||
class="grid items-center space-x-4 p-2 border-b border-outline-gray-modals"
|
||||
class="grid items-center gap-x-4 p-2 border-b border-outline-gray-modals"
|
||||
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
||||
>
|
||||
<div
|
||||
@@ -21,7 +21,7 @@
|
||||
<div
|
||||
v-for="(row, rowIndex) in rows"
|
||||
:key="rowIndex"
|
||||
class="grid items-center space-x-4 p-2"
|
||||
class="grid items-center gap-x-4 p-2"
|
||||
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
||||
>
|
||||
<template v-for="key in Object.keys(row)" :key="key">
|
||||
@@ -47,7 +47,7 @@
|
||||
<div
|
||||
v-if="menuOpenIndex === rowIndex"
|
||||
ref="menuRef"
|
||||
class="absolute right-0 w-32 z-50 bg-surface-modal border border-outline-gray-modals rounded-md shadow-sm"
|
||||
class="absolute end-0 w-32 z-50 bg-surface-modal border border-outline-gray-modals rounded-md shadow-sm"
|
||||
:class="
|
||||
rowIndex == (rows?.length ?? 0) - 1
|
||||
? 'bottom-full mb-1'
|
||||
@@ -56,7 +56,7 @@
|
||||
>
|
||||
<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"
|
||||
class="flex items-center gap-x-2 w-full text-start px-3 py-2 text-sm text-ink-red-3"
|
||||
>
|
||||
<Trash2 class="size-4 stroke-1.5" />
|
||||
<span>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<template #target="{ togglePopover }">
|
||||
<button
|
||||
@click="openPopover(togglePopover)"
|
||||
class="flex w-full items-center space-x-2 focus:outline-none bg-surface-gray-2 rounded h-7 py-1.5 px-2 hover:bg-surface-gray-3 focus:bg-surface-white border border-gray-100 hover:border-outline-gray-modals focus:border-outline-gray-4"
|
||||
class="flex w-full items-center gap-x-2 focus:outline-none bg-surface-gray-2 rounded h-7 py-1.5 px-2 hover:bg-surface-gray-3 focus:bg-surface-white border border-gray-100 hover:border-outline-gray-modals focus:border-outline-gray-4"
|
||||
>
|
||||
<component
|
||||
v-if="selectedIcon"
|
||||
|
||||
@@ -30,28 +30,48 @@
|
||||
</template>
|
||||
|
||||
<template #footer="{ value, close }">
|
||||
<div v-if="attrs.onCreate">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Create New')"
|
||||
@click="attrs.onCreate(value, close)"
|
||||
<div v-if="creating" class="flex items-center gap-1">
|
||||
<button
|
||||
class="p-1 rounded hover:bg-surface-gray-3 text-ink-gray-5"
|
||||
@click="creating = false"
|
||||
:aria-label="__('Cancel')"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
<ArrowLeft class="size-4 stroke-1.5" />
|
||||
</button>
|
||||
<FormControl
|
||||
v-model="newItemName"
|
||||
class="flex-1 min-w-0"
|
||||
size="sm"
|
||||
:placeholder="__(props.inlineCreatePlaceholder)"
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
:disabled="!newItemName.trim()"
|
||||
@click="submitCreate"
|
||||
:aria-label="__('Create')"
|
||||
>
|
||||
{{ __('Create') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div v-else class="flex justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Clear')"
|
||||
@click="() => clearValue(close)"
|
||||
:aria-label="__('Clear')"
|
||||
>
|
||||
{{ __('Clear') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="props.onCreate"
|
||||
variant="ghost"
|
||||
@click="handleCreate(close)"
|
||||
:aria-label="__('Create New')"
|
||||
>
|
||||
<template #prefix>
|
||||
<X class="h-4 w-4 stroke-1.5" />
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Create New') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -63,8 +83,8 @@
|
||||
<script setup>
|
||||
import Autocomplete from '@/components/Controls/Autocomplete.vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import { createResource, Button, FormControl } from 'frappe-ui'
|
||||
import { Plus, ArrowLeft } from 'lucide-vue-next'
|
||||
import { useAttrs, computed, ref } from 'vue'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
|
||||
@@ -85,17 +105,32 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inlineCreate: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inlineCreatePlaceholder: {
|
||||
type: String,
|
||||
default: 'Enter...',
|
||||
},
|
||||
onCreate: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
const attrs = useAttrs()
|
||||
const valuePropPassed = computed(() => 'value' in attrs)
|
||||
const creating = ref(false)
|
||||
const newItemName = ref('')
|
||||
|
||||
const value = computed({
|
||||
get: () => (valuePropPassed.value ? attrs.value : props.modelValue),
|
||||
set: (val) => {
|
||||
return (
|
||||
val && emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
|
||||
val?.value &&
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val.value)
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -104,6 +139,26 @@ const autocomplete = ref(null)
|
||||
const text = ref('')
|
||||
const settingsStore = useSettings()
|
||||
|
||||
function handleCreate(close) {
|
||||
if (props.inlineCreate) {
|
||||
creating.value = true
|
||||
return
|
||||
}
|
||||
if (props.onCreate) {
|
||||
props.onCreate(null, close)
|
||||
}
|
||||
}
|
||||
|
||||
function submitCreate() {
|
||||
if (!newItemName.value.trim() || !props.onCreate) return
|
||||
const value = newItemName.value.trim()
|
||||
props.onCreate(value, () => {
|
||||
creating.value = false
|
||||
newItemName.value = ''
|
||||
reload()
|
||||
})
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
() => autocomplete.value?.query,
|
||||
(val) => {
|
||||
@@ -139,7 +194,7 @@ const options = createResource({
|
||||
params: {
|
||||
txt: text.value,
|
||||
doctype: props.doctype,
|
||||
filters: props.filters,
|
||||
filters: JSON.stringify(props.filters),
|
||||
},
|
||||
transform: (data) => {
|
||||
return data.map((option) => {
|
||||
@@ -152,12 +207,12 @@ const options = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const reload = (val) => {
|
||||
const reload = (val = '') => {
|
||||
options.update({
|
||||
params: {
|
||||
txt: val,
|
||||
doctype: props.doctype,
|
||||
filters: props.filters,
|
||||
filters: JSON.stringify(props.filters),
|
||||
},
|
||||
})
|
||||
options.reload()
|
||||
@@ -177,4 +232,6 @@ const labelClasses = computed(() => {
|
||||
'text-ink-gray-5',
|
||||
]
|
||||
})
|
||||
|
||||
defineExpose({ reload })
|
||||
</script>
|
||||
|
||||
@@ -6,18 +6,34 @@
|
||||
</label>
|
||||
<Combobox v-model="selectedValue" nullable v-slot="{ open }">
|
||||
<div class="relative w-full">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="form-input w-full focus-visible:!ring-0"
|
||||
type="text"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value
|
||||
}
|
||||
"
|
||||
autocomplete="off"
|
||||
@focus="onFocus"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-1.5 w-full rounded-lg border border-[--surface-gray-2] bg-surface-gray-2 px-2 py-1.5 cursor-text transition-colors focus-within:bg-surface-white focus-within:border-outline-gray-4 focus-within:shadow-sm focus-within:ring-0 focus-within:ring-2 focus-within:ring-outline-gray-3"
|
||||
@click="focusInput"
|
||||
>
|
||||
<button
|
||||
v-for="value in values"
|
||||
:key="value"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 bg-surface-white border border-outline-gray-2 text-ink-gray-7 ps-2 pe-1.5 py-0.5 rounded text-base leading-5"
|
||||
@click.stop="removeValue(value)"
|
||||
>
|
||||
<span>{{ value }}</span>
|
||||
<X class="size-3.5 stroke-1.5 shrink-0" />
|
||||
</button>
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="flex-1 min-w-[4rem] border-none outline-none bg-transparent p-0 text-base focus:ring-0"
|
||||
type="text"
|
||||
:placeholder="!values?.length ? __('Search...') : ''"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value
|
||||
}
|
||||
"
|
||||
autocomplete="off"
|
||||
@focus="onFocus"
|
||||
/>
|
||||
</div>
|
||||
<ComboboxButton ref="trigger" class="hidden" />
|
||||
<ComboboxOptions
|
||||
v-show="open"
|
||||
@@ -26,7 +42,7 @@
|
||||
>
|
||||
<div
|
||||
class="flex-1 my-1 overflow-y-auto px-1.5"
|
||||
:class="options.length ? 'min-h-[6rem]' : 'min-h-[3.8rem]'"
|
||||
:class="options.length ? 'min-h-[6rem]' : 'min-h-[1rem]'"
|
||||
>
|
||||
<template v-if="options.length">
|
||||
<ComboboxOption
|
||||
@@ -80,21 +96,6 @@
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</Combobox>
|
||||
|
||||
<!-- Selected values -->
|
||||
<div v-if="values?.length" class="grid grid-cols-2 gap-2 mt-1">
|
||||
<div
|
||||
v-for="value in values"
|
||||
:key="value"
|
||||
class="flex items-center justify-between break-all bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md"
|
||||
>
|
||||
<span>{{ value }}</span>
|
||||
<X
|
||||
class="size-4 stroke-1.5 cursor-pointer"
|
||||
@click="removeValue(value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -106,7 +107,7 @@ import {
|
||||
ComboboxOptions,
|
||||
ComboboxOption,
|
||||
} from '@headlessui/vue'
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import { createResource, Button, toast } from 'frappe-ui'
|
||||
import { ref, computed, useAttrs, watch } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { X, Plus } from 'lucide-vue-next'
|
||||
@@ -115,7 +116,9 @@ const props = defineProps({
|
||||
label: String,
|
||||
size: { type: String, default: 'sm' },
|
||||
doctype: { type: String, required: true },
|
||||
filters: { type: Object, default: () => ({}) },
|
||||
filters: { type: [Object, Array], default: () => ({}) },
|
||||
url: { type: String, default: 'frappe.desk.search.search_link' },
|
||||
searchParams: { type: Object, default: () => ({}) },
|
||||
validate: Function,
|
||||
errorMessage: {
|
||||
type: Function,
|
||||
@@ -124,22 +127,18 @@ const props = defineProps({
|
||||
required: Boolean,
|
||||
})
|
||||
|
||||
const values = defineModel()
|
||||
const values = defineModel({ default: () => [] })
|
||||
const attrs = useAttrs()
|
||||
const trigger = ref(null)
|
||||
const query = ref('')
|
||||
const text = ref('')
|
||||
const selectedValue = ref(null)
|
||||
const error = ref(null)
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
watch(selectedValue, (val) => {
|
||||
if (!val?.value) return
|
||||
query.value = ''
|
||||
addValue(val.value)
|
||||
selectedValue.value = null
|
||||
emit('update:modelValue', values.value)
|
||||
})
|
||||
|
||||
watchDebounced(
|
||||
@@ -153,14 +152,27 @@ watchDebounced(
|
||||
{ debounce: 300, immediate: true }
|
||||
)
|
||||
|
||||
const filterOptions = createResource({
|
||||
url: 'frappe.desk.search.search_link',
|
||||
method: 'POST',
|
||||
auto: true,
|
||||
params: {
|
||||
txt: text.value,
|
||||
doctype: props.doctype,
|
||||
// Refetch when filters or searchParams change
|
||||
watch(
|
||||
() => [props.filters, props.searchParams],
|
||||
() => {
|
||||
reload(text.value)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
function getParams(txt) {
|
||||
return {
|
||||
txt,
|
||||
doctype: props.doctype,
|
||||
filters: JSON.stringify(props.filters),
|
||||
...props.searchParams,
|
||||
}
|
||||
}
|
||||
|
||||
const filterOptions = createResource({
|
||||
url: props.url,
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
@@ -170,10 +182,7 @@ const options = computed(() => {
|
||||
|
||||
function reload(val) {
|
||||
filterOptions.update({
|
||||
params: {
|
||||
txt: val,
|
||||
doctype: props.doctype,
|
||||
},
|
||||
params: getParams(val),
|
||||
})
|
||||
filterOptions.reload()
|
||||
}
|
||||
@@ -186,34 +195,30 @@ function onFocus() {
|
||||
}
|
||||
|
||||
function addValue(value) {
|
||||
error.value = null
|
||||
|
||||
if (!value) return
|
||||
|
||||
const splitValues = value.split(',')
|
||||
let newValues = [...(values.value || [])]
|
||||
|
||||
splitValues.forEach((val) => {
|
||||
val = val.trim()
|
||||
|
||||
if (!val) return
|
||||
if (values.value?.includes(val)) return
|
||||
if (newValues.includes(val)) return
|
||||
|
||||
if (props.validate && !props.validate(val)) {
|
||||
error.value = props.errorMessage(val)
|
||||
toast.error(props.errorMessage(val))
|
||||
return
|
||||
}
|
||||
|
||||
if (!values.value) values.value = [val]
|
||||
else values.value.push(val)
|
||||
newValues.push(val)
|
||||
})
|
||||
|
||||
values.value = newValues
|
||||
}
|
||||
|
||||
function removeValue(value) {
|
||||
let indexToRemove = values.value.indexOf(value)
|
||||
if (indexToRemove > -1) {
|
||||
values.value.splice(indexToRemove, 1)
|
||||
}
|
||||
emit('update:modelValue', values.value)
|
||||
values.value = values.value.filter((v) => v !== value)
|
||||
}
|
||||
|
||||
const labelClasses = computed(() => [
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
@mouseleave="hoveredRating = 0"
|
||||
>
|
||||
<Star
|
||||
class="fill-gray-400 text-gray-50 stroke-1 mr-1 cursor-pointer"
|
||||
class="fill-gray-400 text-gray-50 stroke-1 me-1 cursor-pointer"
|
||||
:class="iconClasses(index)"
|
||||
@click="markRating(index)"
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
:fileTypes="[fileType]"
|
||||
:validateFile="(file: File) => validateFile(file, true, type)"
|
||||
@success="(file: File) => saveFile(file)"
|
||||
@failure="onUploadFailure"
|
||||
>
|
||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||
<div class="flex items-center">
|
||||
@@ -18,9 +19,9 @@
|
||||
class="size-5 stroke-1 text-ink-gray-7"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<Button @click="openFileSelector">
|
||||
{{ __('Upload') }}
|
||||
<div class="ms-4">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{ uploading ? `${__('Uploading')} ${progress}%` : __('Upload') }}
|
||||
</Button>
|
||||
<div class="mt-1 text-ink-gray-5 text-sm leading-5">
|
||||
{{ __(description) }}
|
||||
@@ -38,14 +39,14 @@
|
||||
'border object-cover',
|
||||
shape === 'circle'
|
||||
? 'w-20 h-20 rounded-full'
|
||||
: 'w-44 h-auto min-h-20 rounded-md',
|
||||
: 'w-44 h-auto min-h-20 max-h-32 rounded-md',
|
||||
]"
|
||||
/>
|
||||
<video v-else controls class="border rounded-md w-44 h-auto">
|
||||
<source :src="modelValue" />
|
||||
{{ __('Your browser does not support the video tag.') }}
|
||||
</video>
|
||||
<div class="ml-4">
|
||||
<div class="ms-4">
|
||||
<Button @click="removeImage()">
|
||||
{{ __('Remove') }}
|
||||
</Button>
|
||||
@@ -62,7 +63,7 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { validateFile } from '@/utils'
|
||||
import { Button, FileUploader } from 'frappe-ui'
|
||||
import { Button, FileUploader, toast } from 'frappe-ui'
|
||||
import { Image, Video } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
|
||||
@@ -100,4 +101,14 @@ const saveFile = (file: any) => {
|
||||
const removeImage = () => {
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
|
||||
const onUploadFailure = (error: any) => {
|
||||
let message = __('Error Uploading File')
|
||||
if (error?._server_messages) {
|
||||
message = JSON.parse(JSON.parse(error._server_messages)[0]).message
|
||||
} else if (error?.exc) {
|
||||
message = JSON.parse(error.exc)[0].split('\n').slice(-2, -1)[0]
|
||||
}
|
||||
toast.error(message)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="course.title"
|
||||
class="flex flex-col h-full rounded-md overflow-auto text-ink-gray-9"
|
||||
class="flex flex-col h-full rounded-md overflow-auto text-ink-gray-9 bg-surface-cards"
|
||||
style="min-height: 350px"
|
||||
>
|
||||
<div
|
||||
@@ -10,7 +10,7 @@
|
||||
course.image
|
||||
? { backgroundImage: `url('${encodeURI(course.image)}')` }
|
||||
: {
|
||||
backgroundImage: getGradientColor(),
|
||||
backgroundImage: gradientColor,
|
||||
backgroundBlendMode: 'screen',
|
||||
}
|
||||
"
|
||||
@@ -18,7 +18,7 @@
|
||||
<!-- <div class="flex items-center flex-wrap relative top-4 px-2 w-fit">
|
||||
<div
|
||||
v-if="course.featured"
|
||||
class="flex items-center space-x-1 text-xs text-ink-amber-3 bg-surface-white border border-outline-amber-1 px-2 py-0.5 rounded-md mr-1 mb-1"
|
||||
class="flex items-center gap-x-1 text-xs text-ink-amber-3 bg-surface-white border border-outline-amber-1 px-2 py-0.5 rounded-md me-1 mb-1"
|
||||
>
|
||||
<Star class="size-3 stroke-2" />
|
||||
<span>
|
||||
@@ -28,7 +28,7 @@
|
||||
<div
|
||||
v-if="course.tags"
|
||||
v-for="tag in course.tags?.split(', ')"
|
||||
class="text-xs border bg-surface-white text-ink-gray-9 px-2 py-0.5 rounded-md mb-1 mr-1"
|
||||
class="text-xs border bg-surface-white text-ink-gray-9 px-2 py-0.5 rounded-md mb-1 me-1"
|
||||
>
|
||||
{{ tag }}
|
||||
</div>
|
||||
@@ -52,7 +52,7 @@
|
||||
<div v-if="course.lessons">
|
||||
<Tooltip :text="__('Lessons')">
|
||||
<span class="flex items-center">
|
||||
<BookOpen class="h-4 w-4 stroke-1.5 mr-1" />
|
||||
<BookOpen class="h-4 w-4 stroke-1.5 me-1" />
|
||||
{{ course.lessons }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
@@ -61,7 +61,7 @@
|
||||
<div v-if="course.enrollments">
|
||||
<Tooltip :text="__('Enrolled Students')">
|
||||
<span class="flex items-center">
|
||||
<Users class="h-4 w-4 stroke-1.5 mr-1" />
|
||||
<Users class="h-4 w-4 stroke-1.5 me-1" />
|
||||
{{ formatAmount(course.enrollments) }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
@@ -70,7 +70,7 @@
|
||||
<div v-if="course.rating">
|
||||
<Tooltip :text="__('Average Rating')">
|
||||
<span class="flex items-center">
|
||||
<Star class="h-4 w-4 stroke-1.5 mr-1" />
|
||||
<Star class="h-4 w-4 stroke-1.5 me-1" />
|
||||
{{ course.rating }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
@@ -105,7 +105,7 @@
|
||||
<div class="flex items-center justify-between mt-auto">
|
||||
<div class="flex avatar-group overlap">
|
||||
<div
|
||||
class="h-6 mr-1"
|
||||
class="h-6 me-1"
|
||||
:class="{ 'avatar-group overlap': course.instructors.length > 1 }"
|
||||
>
|
||||
<UserAvatar
|
||||
@@ -116,7 +116,7 @@
|
||||
<CourseInstructors :instructors="course.instructors" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<div v-if="course.paid_course" class="font-semibold">
|
||||
{{ course.price }}
|
||||
</div>
|
||||
@@ -137,6 +137,8 @@ import { Award, BookOpen, GraduationCap, Star, Users } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { Tooltip } from 'frappe-ui'
|
||||
import { formatAmount } from '@/utils'
|
||||
import { theme } from '@/utils/theme'
|
||||
import { computed, watch } from 'vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
@@ -151,12 +153,12 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const getGradientColor = () => {
|
||||
let theme = localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
|
||||
const gradientColor = computed(() => {
|
||||
let themeMode = theme.value === 'dark' ? 'darkMode' : 'lightMode'
|
||||
let color = props.course.card_gradient?.toLowerCase() || 'blue'
|
||||
let colorMap = colors[theme][color]
|
||||
let colorMap = colors[themeMode][color]
|
||||
return `linear-gradient(to top right, black, ${colorMap[400]})`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.course-card-pills {
|
||||
@@ -182,7 +184,7 @@ const getGradientColor = () => {
|
||||
}
|
||||
|
||||
.avatar-group.overlap .avatar + .avatar {
|
||||
margin-left: calc(-8px);
|
||||
margin-inline-start: calc(-8px);
|
||||
}
|
||||
|
||||
.short-introduction {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{{ course.data.price }}
|
||||
</div>
|
||||
<div v-if="!readOnlyMode">
|
||||
<div v-if="course.data.membership" class="space-y-2">
|
||||
<div v-if="course.data.membership" class="space-y-2 mb-8">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Lesson',
|
||||
@@ -46,7 +46,7 @@
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" size="md" class="w-full">
|
||||
<Button variant="solid" size="md" class="w-full mb-8">
|
||||
<template #prefix>
|
||||
<CreditCard class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
@@ -67,7 +67,7 @@
|
||||
v-else-if="!isAdmin"
|
||||
@click="enrollStudent()"
|
||||
variant="solid"
|
||||
class="w-full"
|
||||
class="w-full mb-8"
|
||||
size="md"
|
||||
>
|
||||
<template #prefix>
|
||||
@@ -90,24 +90,26 @@
|
||||
{{ __('Get Certificate') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class="font-medium text-ink-gray-9"
|
||||
:class="{ 'mt-8': !readOnlyMode }"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="font-medium text-ink-gray-9">
|
||||
{{ __('This course has:') }}
|
||||
</div>
|
||||
<div class="flex items-center text-ink-gray-9">
|
||||
<BookOpen class="h-4 w-4 stroke-1.5" />
|
||||
<span class="ml-2">
|
||||
{{ course.data.lessons }} {{ __('Lessons') }}
|
||||
<span class="ms-2">
|
||||
{{ course.data.lessons }}
|
||||
{{ course.data.lessons > 1 ? __('lessons') : __('lesson') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center text-ink-gray-9">
|
||||
<Users class="h-4 w-4 stroke-1.5" />
|
||||
<span class="ml-2">
|
||||
<span class="ms-2">
|
||||
{{ formatAmount(course.data.enrollments) }}
|
||||
{{ __('Enrolled Students') }}
|
||||
{{
|
||||
course.data.enrollments > 1
|
||||
? __('enrolled students')
|
||||
: __('enrolled student')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -115,8 +117,8 @@
|
||||
class="flex items-center text-ink-gray-9"
|
||||
>
|
||||
<Star class="size-4 stroke-1.5 fill-yellow-500 text-transparent" />
|
||||
<span class="ml-2">
|
||||
{{ course.data.rating }} {{ __('Rating') }}
|
||||
<span class="ms-2">
|
||||
{{ course.data.rating }} {{ __('average rating') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -124,7 +126,7 @@
|
||||
class="flex items-center font-semibold text-ink-gray-9"
|
||||
>
|
||||
<GraduationCap class="h-4 w-4 stroke-2" />
|
||||
<span class="ml-2">
|
||||
<span class="ms-2">
|
||||
{{ __('Certificate of Completion') }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -133,7 +135,7 @@
|
||||
class="flex items-center font-semibold text-ink-gray-9"
|
||||
>
|
||||
<GraduationCap class="h-4 w-4 stroke-2" />
|
||||
<span class="ml-2">
|
||||
<span class="ms-2">
|
||||
{{ __('Paid Certificate after Evaluation') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="">
|
||||
<div
|
||||
v-if="title && (outline.data?.length || allowEdit)"
|
||||
class="flex items-center justify-between space-x-2 mb-4 px-2"
|
||||
class="flex items-center justify-between gap-x-2 mb-4 px-2"
|
||||
:class="{
|
||||
'sticky top-0 z-10 bg-surface-white border-b px-3 py-2.5 sm:px-5':
|
||||
allowEdit,
|
||||
@@ -46,20 +46,20 @@
|
||||
>
|
||||
<ChevronRight
|
||||
:class="{
|
||||
'rotate-90 transform duration-200': open,
|
||||
'duration-200': !open,
|
||||
'rotate-90': open,
|
||||
'rtl:rotate-180': !open,
|
||||
hidden: chapter.is_scorm_package,
|
||||
open: index == 1,
|
||||
}"
|
||||
class="h-4 w-4 text-ink-gray-9 stroke-1"
|
||||
class="h-4 w-4 text-ink-gray-9 stroke-1 transform duration-200"
|
||||
/>
|
||||
<div
|
||||
class="text-base text-left text-ink-gray-9 font-medium leading-5 ml-2"
|
||||
class="text-base text-start text-ink-gray-9 font-medium leading-5 ms-2"
|
||||
@click="redirectToChapter(chapter)"
|
||||
>
|
||||
{{ chapter.title }}
|
||||
</div>
|
||||
<div class="flex ml-auto space-x-4">
|
||||
<div class="flex ms-auto gap-x-4">
|
||||
<Tooltip :text="__('Edit Chapter')" placement="bottom">
|
||||
<FilePenLine
|
||||
v-if="allowEdit"
|
||||
@@ -75,6 +75,12 @@
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Check
|
||||
v-if="
|
||||
chapter.is_scorm_package && isScormChapterComplete(chapter)
|
||||
"
|
||||
class="h-4 w-4 text-green-700"
|
||||
/>
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel v-if="!chapter.is_scorm_package">
|
||||
<Draggable
|
||||
@@ -88,7 +94,7 @@
|
||||
>
|
||||
<template #item="{ element: lesson }">
|
||||
<div
|
||||
class="outline-lesson pl-8 py-2 pr-4 text-ink-gray-9"
|
||||
class="outline-lesson ps-8 py-2 pe-4 text-ink-gray-9"
|
||||
:class="
|
||||
isActiveLesson(lesson.number) ? 'bg-surface-gray-3' : ''
|
||||
"
|
||||
@@ -106,23 +112,23 @@
|
||||
<div class="flex items-center text-sm leading-5 group">
|
||||
<MonitorPlay
|
||||
v-if="lesson.icon === 'icon-youtube'"
|
||||
class="h-4 w-4 stroke-1 mr-2"
|
||||
class="h-4 w-4 stroke-1 me-2"
|
||||
/>
|
||||
<HelpCircle
|
||||
v-else-if="lesson.icon === 'icon-quiz'"
|
||||
class="h-4 w-4 stroke-1 mr-2"
|
||||
class="h-4 w-4 stroke-1 me-2"
|
||||
/>
|
||||
<NotebookPen
|
||||
v-else-if="lesson.icon === 'icon-assignment'"
|
||||
class="h-4 w-4 stroke-1 mr-2"
|
||||
class="h-4 w-4 stroke-1 me-2"
|
||||
/>
|
||||
<SquareCode
|
||||
v-else-if="lesson.icon === 'icon-code'"
|
||||
class="h-4 w-4 stroke-1 mr-2"
|
||||
class="h-4 w-4 stroke-1 me-2"
|
||||
/>
|
||||
<FileText
|
||||
v-else-if="lesson.icon === 'icon-list'"
|
||||
class="h-4 w-4 text-ink-gray-9 stroke-1 mr-2"
|
||||
class="h-4 w-4 text-ink-gray-9 stroke-1 me-2"
|
||||
/>
|
||||
{{ lesson.title }}
|
||||
<Trash2
|
||||
@@ -130,18 +136,18 @@
|
||||
@click.prevent="
|
||||
trashLesson(lesson.name, chapter.name)
|
||||
"
|
||||
class="h-4 w-4 text-ink-red-3 ml-auto invisible group-hover:visible"
|
||||
class="h-4 w-4 text-ink-red-3 ms-auto invisible group-hover:visible"
|
||||
/>
|
||||
<Check
|
||||
v-if="lesson.is_complete"
|
||||
class="h-4 w-4 text-green-700 ml-2"
|
||||
class="h-4 w-4 text-green-700 ms-2"
|
||||
/>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
|
||||
<div v-if="allowEdit" class="flex mt-2 mb-4 ps-8">
|
||||
<router-link
|
||||
v-if="!chapter.is_scorm_package"
|
||||
:to="{
|
||||
@@ -189,7 +195,6 @@ import {
|
||||
Plus,
|
||||
SquareCode,
|
||||
Trash2,
|
||||
Notebook,
|
||||
} from 'lucide-vue-next'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ChapterModal from '@/components/Modals/ChapterModal.vue'
|
||||
@@ -402,6 +407,10 @@ const redirectToChapter = (chapter) => {
|
||||
})
|
||||
}
|
||||
|
||||
const isScormChapterComplete = (chapter) => {
|
||||
return chapter.lessons?.length && chapter.lessons.every((l) => l.is_complete)
|
||||
}
|
||||
|
||||
const isActiveLesson = (lessonNumber) => {
|
||||
return (
|
||||
route.params.chapterNumber == lessonNumber.split('-')[0] &&
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<Button
|
||||
v-if="membership && !hasReviewed.data"
|
||||
@click="openReviewModal()"
|
||||
class="float-right"
|
||||
class="float-end"
|
||||
>
|
||||
{{ __('Write a Review') }}
|
||||
</Button>
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
<div class="grid gap-8 mt-10">
|
||||
<div v-for="(review, index) in reviews.data">
|
||||
<div class="flex items-center">
|
||||
<div class="flex">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
@@ -28,14 +28,14 @@
|
||||
params: { username: review.owner_details.username },
|
||||
}"
|
||||
>
|
||||
<span class="text-lg font-medium mr-4 text-ink-gray-7">
|
||||
<span class="text-lg font-medium me-4 text-ink-gray-7">
|
||||
{{ review.owner_details.full_name }}
|
||||
</span>
|
||||
</router-link>
|
||||
<span class="text-ink-gray-7">
|
||||
{{ review.creation }}
|
||||
</span>
|
||||
<div class="flex mt-2 space-x-1">
|
||||
<div class="flex mt-2 gap-x-1">
|
||||
<Star
|
||||
v-for="index in 5"
|
||||
class="size-4 text-transparent rounded-sm"
|
||||
@@ -46,11 +46,11 @@
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="review.review" class="mt-4 leading-5 text-ink-gray-7">
|
||||
{{ review.review }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="review.review" class="mt-4 leading-5 text-ink-gray-7">
|
||||
{{ review.review }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex h-screen w-screen">
|
||||
<div class="h-full border-r bg-surface-menu-bar">
|
||||
<div class="h-full border-e bg-surface-menu-bar">
|
||||
<AppSidebar />
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col h-full overflow-auto bg-surface-white">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<ChevronLeft class="w-5 h-5 stroke-1.5 text-ink-gray-7" />
|
||||
</template>
|
||||
</Button>
|
||||
<span class="text-lg font-semibold ml-2 text-ink-gray-9">
|
||||
<span class="text-lg font-semibold ms-2 text-ink-gray-9">
|
||||
{{ topic.title }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -18,11 +18,11 @@
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center text-ink-gray-5">
|
||||
<UserAvatar :user="reply.user" class="mr-2" />
|
||||
<UserAvatar :user="reply.user" class="me-2" />
|
||||
<span>
|
||||
{{ reply.user.full_name }}
|
||||
</span>
|
||||
<span class="text-sm ml-2">
|
||||
<span class="text-sm ms-2">
|
||||
{{ timeAgo(reply.creation) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<Button
|
||||
v-if="!singleThread && !readOnlyMode"
|
||||
class="float-right"
|
||||
class="float-end"
|
||||
@click="openTopicModal()"
|
||||
>
|
||||
<template #prefix>
|
||||
@@ -21,7 +21,7 @@
|
||||
class="flex items-center cursor-pointer py-5 w-full"
|
||||
:class="{ 'border-b': index + 1 != topics.data.length }"
|
||||
>
|
||||
<UserAvatar :user="topic.user" size="2xl" class="mr-4" />
|
||||
<UserAvatar :user="topic.user" size="2xl" class="me-4" />
|
||||
<div>
|
||||
<div class="text-lg font-semibold mb-1 text-ink-gray-7">
|
||||
{{ topic.title }}
|
||||
@@ -30,7 +30,7 @@
|
||||
<span>
|
||||
{{ topic.user.full_name }}
|
||||
</span>
|
||||
<span class="text-sm ml-3">
|
||||
<span class="text-sm ms-3">
|
||||
{{ timeAgo(topic.creation) }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -51,7 +51,7 @@
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center border-2 border-dashed mt-5 py-8 rounded-md"
|
||||
>
|
||||
<MessageSquareText class="w-7 h-7 text-ink-gray-4 stroke-1.5 mr-2" />
|
||||
<MessageSquareText class="w-7 h-7 text-ink-gray-4 stroke-1.5 me-2" />
|
||||
<div class="mt-2">
|
||||
<div v-if="emptyStateTitle" class="font-medium mb-2">
|
||||
{{ __(emptyStateTitle) }}
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
<div class="text-lg font-semibold text-ink-gray-7 mb-2.5">
|
||||
{{ __('No {0}').format(type?.toLowerCase()) }}
|
||||
</div>
|
||||
<div
|
||||
class="leading-5 text-base w-full md:w-2/5 text-base text-center text-ink-gray-7"
|
||||
>
|
||||
<div class="text-p-base w-full md:w-2/5 text-center text-ink-gray-7">
|
||||
{{
|
||||
__(
|
||||
'There are no {0} currently. Keep an eye out, fresh learning experiences are on the way!'
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<span class="inline-flex items-baseline">
|
||||
<FeatherIcon
|
||||
name="x"
|
||||
class="ml-auto h-4 w-4 text-gray-700"
|
||||
class="ms-auto h-4 w-4 text-gray-700"
|
||||
@click="iosInstallMessage = false"
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div
|
||||
class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-3"
|
||||
>
|
||||
<div class="flex space-x-4 mb-4">
|
||||
<div class="flex gap-x-4 mb-4">
|
||||
<div class="flex flex-col space-y-2 flex-1 break-all">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ job.company_name }}
|
||||
@@ -10,7 +10,7 @@
|
||||
<span class="font-medium text-ink-gray-7 leading-5">
|
||||
{{ job.job_title }}
|
||||
</span>
|
||||
<div class="flex items-center space-x-1 text-sm text-ink-gray-7">
|
||||
<div class="flex items-center gap-x-1 text-sm text-ink-gray-7">
|
||||
<MapPin class="size-3" />
|
||||
<span>
|
||||
{{ job.location }}{{ job.country ? `, ${job.country}` : '' }}
|
||||
@@ -18,7 +18,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="job.applicants"
|
||||
class="flex items-center space-x-1 text-sm text-ink-gray-7"
|
||||
class="flex items-center gap-x-1 text-sm text-ink-gray-7"
|
||||
>
|
||||
<User class="size-3" />
|
||||
<span>
|
||||
@@ -29,7 +29,7 @@
|
||||
</div>
|
||||
<!-- <img :src="job.company_logo" alt="Company Logo" class="size-8 rounded-full object-contain bg-white" /> -->
|
||||
</div>
|
||||
<div class="space-x-2 mt-auto">
|
||||
<div class="flex gap-x-2 items-center mt-auto">
|
||||
<Badge>
|
||||
{{ job.type }}
|
||||
</Badge>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
>
|
||||
</iframe>
|
||||
</div>
|
||||
<div v-else v-html="markdown.render(block)"></div>
|
||||
<div v-else v-html="renderSafe(block)"></div>
|
||||
</div>
|
||||
<div v-if="quizId">
|
||||
<Quiz :quiz="quizId" />
|
||||
@@ -66,6 +66,7 @@
|
||||
<script setup>
|
||||
import Quiz from '@/components/QuizBlock.vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { useScreenSize } from '@/utils/composables'
|
||||
|
||||
const screenSize = useScreenSize()
|
||||
@@ -75,6 +76,8 @@ const markdown = new MarkdownIt({
|
||||
linkify: true,
|
||||
})
|
||||
|
||||
const renderSafe = (block) => DOMPurify.sanitize(markdown.render(block))
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: String,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<div class="space-y-5 text-ink-gray-9">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center text-sm font-medium space-x-2">
|
||||
<div class="flex items-center text-sm font-medium gap-x-2">
|
||||
<span>
|
||||
{{ __('What does include in preview mean?') }}
|
||||
{{ __('What are Instructor Notes?') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
|
||||
{{
|
||||
__(
|
||||
'If Include in Preview is enabled for a lesson then the lesson will also be accessible to non logged in users.'
|
||||
'Instructor Notes are private notes that only instructors can see. They can be used to provide additional context or guidance for the lesson.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<div class="space-y-2" v-for="(item, key) in contentMap" :key="key">
|
||||
<div
|
||||
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
|
||||
class="flex items-center text-sm font-medium gap-x-2 cursor-pointer"
|
||||
@click="openHelpDialog(key)"
|
||||
>
|
||||
<span>
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="hasPermission() && !props.zoomAccount"
|
||||
class="flex items-center space-x-2 mb-5 bg-surface-amber-1 py-1 px-2 rounded-md text-ink-amber-3 text-xs"
|
||||
>
|
||||
<AlertCircle class="size-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Please add a zoom account to the batch to create live classes.') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Live Class') }}
|
||||
</div>
|
||||
<Button v-if="canCreateClass()" @click="openLiveClassModal">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Add') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="liveClasses.data?.length"
|
||||
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 mt-5"
|
||||
>
|
||||
<div
|
||||
v-for="cls in liveClasses.data"
|
||||
class="flex flex-col border rounded-md h-full text-ink-gray-7 hover:border-outline-gray-3 p-3"
|
||||
:class="{
|
||||
'cursor-pointer': hasPermission() && cls.attendees > 0,
|
||||
}"
|
||||
@click="
|
||||
() => {
|
||||
openAttendanceModal(cls)
|
||||
}
|
||||
"
|
||||
>
|
||||
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
|
||||
{{ cls.title }}
|
||||
</div>
|
||||
<div class="short-introduction">
|
||||
{{ cls.description }}
|
||||
</div>
|
||||
<div class="mt-auto space-y-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Clock class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ dayjs(getClassStart(cls)).format('hh:mm A') }} -
|
||||
{{ dayjs(getClassEnd(cls)).format('hh:mm A') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="canAccessClass(cls)"
|
||||
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
|
||||
>
|
||||
<a
|
||||
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
||||
:href="cls.start_url"
|
||||
target="_blank"
|
||||
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||
:class="cls.join_url ? 'w-full' : 'w-1/2'"
|
||||
>
|
||||
<Monitor class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Start') }}
|
||||
</a>
|
||||
<a
|
||||
:href="cls.join_url"
|
||||
target="_blank"
|
||||
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||
>
|
||||
<Video class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Join') }}
|
||||
</a>
|
||||
</div>
|
||||
<Tooltip
|
||||
v-else-if="hasClassEnded(cls)"
|
||||
:text="__('This class has ended')"
|
||||
placement="right"
|
||||
>
|
||||
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
|
||||
<Info class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Ended') }}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-ink-gray-5 mt-2">
|
||||
{{ __('No live classes scheduled') }}
|
||||
</div>
|
||||
|
||||
<LiveClassModal
|
||||
:batch="props.batch"
|
||||
:zoomAccount="props.zoomAccount"
|
||||
v-model="showLiveClassModal"
|
||||
v-model:reloadLiveClasses="liveClasses"
|
||||
/>
|
||||
|
||||
<LiveClassAttendance
|
||||
v-if="showAttendance"
|
||||
v-model="showAttendance"
|
||||
:live_class="attendanceFor"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createListResource, Button, Tooltip } from 'frappe-ui'
|
||||
import {
|
||||
Plus,
|
||||
Clock,
|
||||
Calendar,
|
||||
Video,
|
||||
Monitor,
|
||||
Info,
|
||||
AlertCircle,
|
||||
} from 'lucide-vue-next'
|
||||
import { inject, ref } from 'vue'
|
||||
import { formatTime } from '@/utils/'
|
||||
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
|
||||
import LiveClassAttendance from '@/components/Modals/LiveClassAttendance.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const showLiveClassModal = ref(false)
|
||||
const dayjs = inject('$dayjs')
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const showAttendance = ref(false)
|
||||
const attendanceFor = ref(null)
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
zoomAccount: String,
|
||||
})
|
||||
|
||||
const liveClasses = createListResource({
|
||||
doctype: 'LMS Live Class',
|
||||
filters: {
|
||||
batch_name: props.batch,
|
||||
},
|
||||
fields: [
|
||||
'title',
|
||||
'description',
|
||||
'time',
|
||||
'date',
|
||||
'duration',
|
||||
'attendees',
|
||||
'start_url',
|
||||
'join_url',
|
||||
'owner',
|
||||
],
|
||||
orderBy: 'date',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const openLiveClassModal = () => {
|
||||
showLiveClassModal.value = true
|
||||
}
|
||||
|
||||
const canCreateClass = () => {
|
||||
if (readOnlyMode) return false
|
||||
if (!props.zoomAccount) return false
|
||||
return hasPermission()
|
||||
}
|
||||
|
||||
const hasPermission = () => {
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
}
|
||||
|
||||
const canAccessClass = (cls) => {
|
||||
if (cls.date < dayjs().format('YYYY-MM-DD')) return false
|
||||
if (cls.date > dayjs().format('YYYY-MM-DD')) return false
|
||||
if (hasClassEnded(cls)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const getClassStart = (cls) => {
|
||||
return new Date(`${cls.date}T${cls.time}`)
|
||||
}
|
||||
|
||||
const getClassEnd = (cls) => {
|
||||
const classStart = getClassStart(cls)
|
||||
return new Date(classStart.getTime() + cls.duration * 60000)
|
||||
}
|
||||
|
||||
const hasClassEnded = (cls) => {
|
||||
const classEnd = getClassEnd(cls)
|
||||
const now = new Date()
|
||||
return now > classEnd
|
||||
}
|
||||
|
||||
const openAttendanceModal = (cls) => {
|
||||
if (!hasPermission()) return
|
||||
if (cls.attendees <= 0) return
|
||||
showAttendance.value = true
|
||||
attendanceFor.value = cls
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.short-introduction {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0.25rem 0 1.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -7,14 +7,14 @@
|
||||
<div class="relative z-20">
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
class="fixed bottom-16 right-2 w-[80%] rounded-md bg-surface-white text-base p-5 space-y-4 shadow-md"
|
||||
class="fixed bottom-16 end-2 w-[80%] rounded-md bg-surface-white text-base p-5 space-y-4 shadow-md"
|
||||
v-if="showMenu"
|
||||
ref="menu"
|
||||
>
|
||||
<div
|
||||
v-for="link in otherLinks"
|
||||
:key="link.label"
|
||||
class="flex items-center space-x-2 cursor-pointer"
|
||||
class="flex items-center gap-x-2 cursor-pointer"
|
||||
@click="handleClick(link)"
|
||||
>
|
||||
<component
|
||||
@@ -28,7 +28,7 @@
|
||||
<!-- Fixed menu -->
|
||||
<div
|
||||
v-if="sidebarSettings.data"
|
||||
class="fixed bottom-0 left-0 w-full flex items-center justify-around border-t border-outline-gray-2 bg-surface-white standalone:pb-4 z-10"
|
||||
class="fixed bottom-0 start-0 w-full flex items-center justify-around border-t border-outline-gray-2 bg-surface-white standalone:pb-4 z-10"
|
||||
>
|
||||
<button
|
||||
v-for="tab in sidebarLinks"
|
||||
@@ -57,7 +57,7 @@
|
||||
import { getSidebarLinks } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { call } from 'frappe-ui'
|
||||
import { watch, ref, onMounted } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import { usersStore } from '@/stores/user'
|
||||
@@ -68,26 +68,13 @@ let { isLoggedIn } = sessionStore()
|
||||
const { sidebarSettings } = useSettings()
|
||||
const router = useRouter()
|
||||
let { userResource } = usersStore()
|
||||
const sidebarLinks = ref(getSidebarLinks())
|
||||
const sidebarLinks = ref([])
|
||||
const otherLinks = ref([])
|
||||
const showMenu = ref(false)
|
||||
const menu = ref(null)
|
||||
const isModerator = ref(false)
|
||||
const isInstructor = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
sidebarSettings.reload(
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
destructureSidebarLinks()
|
||||
filterLinksToShow(data)
|
||||
addOtherLinks()
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const handleOutsideClick = (e) => {
|
||||
if (menu.value && !menu.value.contains(e.target)) {
|
||||
showMenu.value = false
|
||||
@@ -126,65 +113,57 @@ const filterLinksToShow = (data) => {
|
||||
|
||||
const addOtherLinks = () => {
|
||||
if (user) {
|
||||
otherLinks.value.push({
|
||||
label: 'Notifications',
|
||||
icon: 'Bell',
|
||||
to: 'Notifications',
|
||||
})
|
||||
otherLinks.value.push({
|
||||
label: 'Profile',
|
||||
icon: 'UserRound',
|
||||
})
|
||||
otherLinks.value.push({
|
||||
label: 'Log out',
|
||||
icon: 'LogOut',
|
||||
})
|
||||
addLink('Notifications', 'Bell', 'Notifications')
|
||||
addLink('Profile', 'UserRound')
|
||||
addLink('Log out', 'LogOut')
|
||||
} else {
|
||||
otherLinks.value.push({
|
||||
label: 'Log in',
|
||||
icon: 'LogIn',
|
||||
})
|
||||
addLink('Log in', 'LogIn')
|
||||
}
|
||||
}
|
||||
|
||||
watch(userResource, () => {
|
||||
if (userResource.data) {
|
||||
isModerator.value = userResource.data.is_moderator
|
||||
isInstructor.value = userResource.data.is_instructor
|
||||
addPrograms()
|
||||
if (isModerator.value || isInstructor.value) {
|
||||
addProgrammingExercises()
|
||||
addQuizzes()
|
||||
addAssignments()
|
||||
const addLink = (label, icon, to = '') => {
|
||||
if (otherLinks.value.some((link) => link.label === label)) return
|
||||
otherLinks.value.push({
|
||||
label: label,
|
||||
icon: icon,
|
||||
to: to,
|
||||
})
|
||||
}
|
||||
|
||||
const updateSidebarLinks = () => {
|
||||
sidebarLinks.value = getSidebarLinks(true)
|
||||
destructureSidebarLinks()
|
||||
sidebarSettings.reload(
|
||||
{},
|
||||
{
|
||||
onSuccess: async (data) => {
|
||||
filterLinksToShow(data)
|
||||
await addPrograms()
|
||||
if (isModerator.value || isInstructor.value) {
|
||||
addQuizzes()
|
||||
addAssignments()
|
||||
addProgrammingExercises()
|
||||
}
|
||||
addOtherLinks()
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const addQuizzes = () => {
|
||||
otherLinks.value.push({
|
||||
label: 'Quizzes',
|
||||
icon: 'CircleHelp',
|
||||
to: 'Quizzes',
|
||||
})
|
||||
addLink('Quizzes', 'CircleHelp', 'Quizzes')
|
||||
}
|
||||
|
||||
const addAssignments = () => {
|
||||
otherLinks.value.push({
|
||||
label: 'Assignments',
|
||||
icon: 'Pencil',
|
||||
to: 'Assignments',
|
||||
})
|
||||
addLink('Assignments', 'Pencil', 'Assignments')
|
||||
}
|
||||
|
||||
const addProgrammingExercises = () => {
|
||||
otherLinks.value.push({
|
||||
label: 'Programming Exercises',
|
||||
icon: 'Code',
|
||||
to: 'ProgrammingExercises',
|
||||
})
|
||||
addLink('Programming Exercises', 'Code', 'ProgrammingExercises')
|
||||
}
|
||||
|
||||
const addPrograms = async () => {
|
||||
if (sidebarLinks.value.some((link) => link.label === 'Programs')) return
|
||||
let canAddProgram = await checkIfCanAddProgram()
|
||||
if (!canAddProgram) return
|
||||
let activeFor = ['Programs', 'ProgramDetail']
|
||||
@@ -198,7 +177,21 @@ const addPrograms = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
userResource,
|
||||
async () => {
|
||||
await userResource.promise
|
||||
if (userResource.data) {
|
||||
isModerator.value = userResource.data.is_moderator
|
||||
isInstructor.value = userResource.data.is_instructor
|
||||
}
|
||||
updateSidebarLinks()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const checkIfCanAddProgram = async () => {
|
||||
if (!userResource.data) return false
|
||||
if (isModerator.value || isInstructor.value) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Add Existing User as Evaluator'),
|
||||
size: 'md',
|
||||
actions: [
|
||||
{
|
||||
label: __('Add'),
|
||||
variant: 'solid',
|
||||
loading: submitting,
|
||||
onClick: ({ close }: any) => addEvaluator(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<Link doctype="User" v-model="selectedUser" :label="__('Select User')" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { call, Dialog, toast } from 'frappe-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { cleanError } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = defineModel<boolean>({ default: false })
|
||||
const selectedUser = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
const emit = defineEmits<{
|
||||
added: []
|
||||
}>()
|
||||
|
||||
watch(show, (isOpen) => {
|
||||
if (isOpen) {
|
||||
selectedUser.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
const addEvaluator = async (close?: () => void) => {
|
||||
if (!selectedUser.value?.trim()) {
|
||||
toast.error(__('Please select a user'))
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await call('lms.lms.api.save_role', {
|
||||
user: selectedUser.value,
|
||||
role: 'Batch Evaluator',
|
||||
value: 1,
|
||||
})
|
||||
toast.success(__('Evaluator added successfully'))
|
||||
emit('added')
|
||||
close?.()
|
||||
} catch (err: any) {
|
||||
toast.error(cleanError(err.messages?.[0]) || __('Unable to add evaluator'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -20,11 +20,15 @@
|
||||
:options="assessmentTypes"
|
||||
v-model="assessmentType"
|
||||
:label="__('Type')"
|
||||
placeholder=" "
|
||||
@update:modelValue="() => (assessment = null)"
|
||||
/>
|
||||
<Link
|
||||
v-if="assessmentType"
|
||||
v-model="assessment"
|
||||
:doctype="assessmentType"
|
||||
:label="__('Assessment')"
|
||||
placeholder=" "
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
close()
|
||||
@@ -49,9 +53,9 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, FormControl, createResource, toast } from 'frappe-ui'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = defineModel()
|
||||
const assessmentType = ref(null)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
: __('Edit Assignment')
|
||||
}}
|
||||
</div>
|
||||
<div class="space-y-4 max-h-[75vh] overflow-y-auto">
|
||||
<div class="space-y-4 max-h-[75vh] overflow-y-auto p-1">
|
||||
<FormControl
|
||||
v-model="assignment.title"
|
||||
:label="__('Title')"
|
||||
@@ -43,12 +43,12 @@
|
||||
@change="(val) => (assignment.question = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[10rem] max-h-[18rem] overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-2 mt-5">
|
||||
<div class="flex justify-end gap-x-2 mt-5">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'AssignmentSubmissionList',
|
||||
@@ -72,8 +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'
|
||||
import { sanitizeHTML } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = defineModel()
|
||||
const assignments = defineModel<Assignments>('assignments')
|
||||
@@ -133,7 +133,7 @@ watch(show, (newVal) => {
|
||||
})
|
||||
|
||||
const validateFields = () => {
|
||||
assignment.title = escapeHTML(assignment.title.trim())
|
||||
assignment.title = sanitizeHTML(assignment.title.trim())
|
||||
assignment.question = sanitizeHTML(assignment.question)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Add Course'),
|
||||
size: 'sm',
|
||||
title: __('Add a course to the batch'),
|
||||
size: 'lg',
|
||||
actions: [
|
||||
{
|
||||
label: __('Submit'),
|
||||
@@ -41,7 +41,7 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, createResource, toast } from 'frappe-ui'
|
||||
import { Dialog, toast } from 'frappe-ui'
|
||||
import { ref, inject } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
@@ -63,37 +63,28 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const createBatchCourse = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'Batch Course',
|
||||
parent: props.batch,
|
||||
parenttype: 'LMS Batch',
|
||||
parentfield: 'courses',
|
||||
course: course.value,
|
||||
evaluator: evaluator.value,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const addCourse = (close) => {
|
||||
createBatchCourse.submit(
|
||||
{},
|
||||
courses.value.insert.submit(
|
||||
{
|
||||
course: course.value,
|
||||
evaluator: evaluator.value,
|
||||
parent: props.batch,
|
||||
parenttype: 'LMS Batch',
|
||||
parentfield: 'courses',
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
if (user.data?.is_system_manager)
|
||||
updateOnboardingStep('add_batch_course')
|
||||
|
||||
close()
|
||||
courses.value.reload()
|
||||
course.value = null
|
||||
evaluator.value = null
|
||||
toast.success(__('Course added to batch successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.log(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: 'xl',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div class="p-5 space-y-10 text-base">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Avatar :image="student.user_image" size="3xl" />
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ student.full_name }}
|
||||
</div>
|
||||
<Badge
|
||||
v-if="
|
||||
Object.keys(student.assessments).length ||
|
||||
Object.keys(student.courses).length
|
||||
"
|
||||
:theme="student.progress === 100 ? 'green' : 'red'"
|
||||
>
|
||||
{{ student.progress }}% {{ __('Complete') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-7">
|
||||
{{ student.email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Assessments -->
|
||||
<div
|
||||
v-if="Object.keys(student.assessments).length"
|
||||
class="space-y-2 text-sm"
|
||||
>
|
||||
<div
|
||||
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
|
||||
>
|
||||
<span class="flex-1">
|
||||
{{ __('Assessment') }}
|
||||
</span>
|
||||
<span>
|
||||
{{ __('Percentage/Status') }}
|
||||
</span>
|
||||
</div>
|
||||
<router-link
|
||||
v-for="assessment in Object.keys(student.assessments)"
|
||||
class="flex items-center text-ink-gray-7 font-medium"
|
||||
:to="{
|
||||
name:
|
||||
student.assessments[assessment].type == 'LMS Assignment'
|
||||
? 'AssignmentSubmission'
|
||||
: '',
|
||||
params:
|
||||
student.assessments[assessment].type == 'LMS Assignment'
|
||||
? {
|
||||
assignmentID:
|
||||
student.assessments[assessment].assessment,
|
||||
submissionName:
|
||||
student.assessments[assessment].submission,
|
||||
}
|
||||
: {},
|
||||
}"
|
||||
>
|
||||
<span class="flex-1">
|
||||
{{ assessment }}
|
||||
</span>
|
||||
<span v-if="isAssignment(student.assessments[assessment].status)">
|
||||
<Badge
|
||||
:theme="
|
||||
getStatusTheme(student.assessments[assessment].status)
|
||||
"
|
||||
>
|
||||
{{ student.assessments[assessment].status }}
|
||||
</Badge>
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ student.assessments[assessment].status }}
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Courses -->
|
||||
<div
|
||||
v-if="Object.keys(student.courses).length"
|
||||
class="space-y-2 text-sm"
|
||||
>
|
||||
<div
|
||||
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
|
||||
>
|
||||
<span class="flex-1">
|
||||
{{ __('Courses') }}
|
||||
</span>
|
||||
<span>
|
||||
{{ __('Progress') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="course in Object.keys(student.courses)"
|
||||
class="flex items-center text-ink-gray-7 font-medium"
|
||||
>
|
||||
<span class="flex-1">
|
||||
{{ course }}
|
||||
</span>
|
||||
<span>
|
||||
{{ Math.floor(student.courses[course]) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Heatmap -->
|
||||
<StudentHeatmap :member="student.email" :days="120" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Avatar, Badge, Dialog } from 'frappe-ui'
|
||||
import StudentHeatmap from '@/components/StudentHeatmap.vue'
|
||||
|
||||
const show = defineModel()
|
||||
const props = defineProps({
|
||||
student: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const isAssignment = (value) => {
|
||||
return isNaN(value)
|
||||
}
|
||||
|
||||
const getStatusTheme = (status) => {
|
||||
if (status === 'Pass') {
|
||||
return 'green'
|
||||
} else if (status == 'Not Graded') {
|
||||
return 'orange'
|
||||
} else {
|
||||
return 'red'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -16,7 +16,12 @@
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="space-y-4 text-base">
|
||||
<FormControl label="Title" v-model="chapter.title" :required="true" />
|
||||
<FormControl
|
||||
label="Title"
|
||||
v-model="chapter.title"
|
||||
:required="true"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('SCORM Package')"
|
||||
@@ -46,7 +51,7 @@
|
||||
</FileUploader>
|
||||
<div v-else class="">
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<div class="border rounded-md p-2 me-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-ink-gray-7" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
@@ -59,7 +64,7 @@
|
||||
</div>
|
||||
<X
|
||||
@click="() => (chapter.scorm_package = null)"
|
||||
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ms-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
class="absolute left-1/2 mt-3 w-96 max-w-lg -translate-x-1/2 transform rounded-lg bg-surface-white px-4 sm:px-0 lg:max-w-3xl"
|
||||
class="absolute start-1/2 mt-3 w-96 max-w-lg -translate-x-1/2 transform rounded-lg bg-surface-white px-4 sm:px-0 lg:max-w-3xl"
|
||||
>
|
||||
<div
|
||||
class="overflow-hidden rounded-lg p-3 shadow-2xl ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<div class="flex items-center justify-center space-x-2">
|
||||
<div class="flex items-center justify-center gap-x-2">
|
||||
<TextInput
|
||||
type="text"
|
||||
placeholder="search by keyword"
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
<div class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
||||
{{ __('Edit Profile') }}
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Badge v-if="isDirty" theme="orange">
|
||||
{{ __('Not Saved') }}
|
||||
</Badge>
|
||||
<div class="pb-5 float-right">
|
||||
<div class="pb-5 float-end">
|
||||
<Button variant="solid" @click="saveProfile()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
@@ -37,10 +37,12 @@
|
||||
<FormControl
|
||||
v-model="profile.first_name"
|
||||
:label="__('First Name')"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="profile.last_name"
|
||||
:label="__('Last Name')"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl v-model="profile.headline" :label="__('Headline')" />
|
||||
|
||||
@@ -141,7 +143,25 @@ const updateProfile = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const validateMandatoryFields = () => {
|
||||
let missingFields = []
|
||||
if (!profile.first_name) missingFields.push(__('First Name'))
|
||||
if (!profile.last_name) missingFields.push(__('Last Name'))
|
||||
if (!profile.image) missingFields.push(__('Profile Image'))
|
||||
if (missingFields.length) {
|
||||
toast.error(
|
||||
__('Please fill the mandatory fields: {0}').format(
|
||||
missingFields.join(', ')
|
||||
)
|
||||
)
|
||||
console.error('Missing mandatory fields:', missingFields)
|
||||
}
|
||||
return missingFields.length
|
||||
}
|
||||
|
||||
const saveProfile = () => {
|
||||
let missingMandatoryFields = validateMandatoryFields()
|
||||
if (missingMandatoryFields) return
|
||||
profile.bio = sanitizeHTML(profile.bio)
|
||||
updateProfile.submit(
|
||||
{},
|
||||
|
||||
@@ -34,10 +34,11 @@
|
||||
:required="true"
|
||||
:placeholder="__('Your enrollment in {{ batch_name }} is confirmed')"
|
||||
/>
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
:description="__('Use HTML content for the email response')"
|
||||
:label="__('Use HTML')"
|
||||
v-model="template.use_html"
|
||||
type="checkbox"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="template.use_html"
|
||||
@@ -75,7 +76,7 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { call, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||
import { call, Dialog, FormControl, TextEditor, toast, Switch } from 'frappe-ui'
|
||||
import { reactive, watch } from 'vue'
|
||||
import { cleanError } from '@/utils'
|
||||
|
||||
@@ -88,6 +89,7 @@ const props = defineProps({
|
||||
|
||||
const show = defineModel()
|
||||
const emailTemplates = defineModel('emailTemplates')
|
||||
const emit = defineEmits(['created'])
|
||||
const template = reactive({
|
||||
name: '',
|
||||
subject: '',
|
||||
@@ -113,6 +115,7 @@ const createNewTemplate = (close) => {
|
||||
{
|
||||
onSuccess() {
|
||||
emailTemplates.value.reload()
|
||||
emit('created', template.name)
|
||||
refreshForm(close)
|
||||
toast.success(__('Email Template created successfully'))
|
||||
},
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
</div>
|
||||
<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">
|
||||
<div class="flex items-center text-ink-gray-7 gap-x-2">
|
||||
<Calendar class="size-3" />
|
||||
<div class="text-ink-gray-9">
|
||||
{{ dayjs(row.date).format('DD MMMM YYYY') }}
|
||||
@@ -55,6 +55,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!evaluation.course" class="text-ink-gray-7">
|
||||
{{ __('Please select a course to view available slots.') }}
|
||||
</div>
|
||||
<div v-else class="text-ink-red-3">
|
||||
{{ __('No slots available for the selected course.') }}
|
||||
</div>
|
||||
@@ -63,18 +66,12 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
call,
|
||||
createResource,
|
||||
dayjs,
|
||||
Dialog,
|
||||
FormControl,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { call, createResource, Dialog, FormControl, toast } from 'frappe-ui'
|
||||
import { ref, watch, inject } from 'vue'
|
||||
import { Calendar } from 'lucide-vue-next'
|
||||
import { formatTime } from '@/utils/'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
const user = inject('$user')
|
||||
const show = defineModel()
|
||||
const evaluations = defineModel('reloadEvals')
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<div class="flex flex-col space-y-4 text-sm text-ink-gray-8">
|
||||
<Tooltip :text="__('Email ID')">
|
||||
<div class="flex items-center space-x-2 w-fit">
|
||||
<div class="flex items-center gap-x-2 w-fit">
|
||||
<User class="h-4 w-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ event.member }}
|
||||
@@ -23,7 +23,7 @@
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Course')">
|
||||
<div
|
||||
class="flex space-x-2 w-fit cursor-pointer"
|
||||
class="flex gap-x-2 w-fit cursor-pointer"
|
||||
@click="openLink('course', event.course)"
|
||||
>
|
||||
<BookOpen class="h-4 w-4 stroke-1.5" />
|
||||
@@ -34,7 +34,7 @@
|
||||
</Tooltip>
|
||||
<Tooltip v-if="event.batch_title" :text="__('Batch')">
|
||||
<div
|
||||
class="flex space-x-2 w-fit cursor-pointer"
|
||||
class="flex gap-x-2 w-fit cursor-pointer"
|
||||
@click="openLink('batch', event.batch_name)"
|
||||
>
|
||||
<Users class="h-4 w-4 stroke-1.5" />
|
||||
@@ -44,7 +44,7 @@
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Date')">
|
||||
<div class="flex items-center space-x-2 w-fit">
|
||||
<div class="flex items-center gap-x-2 w-fit">
|
||||
<Calendar class="h-4 w-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ dayjs(event.date).format('DD MMM YYYY') }}
|
||||
@@ -52,7 +52,7 @@
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Time')">
|
||||
<div class="flex items-center space-x-2 w-fit">
|
||||
<div class="flex items-center gap-x-2 w-fit">
|
||||
<Clock class="h-4 w-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ formatTime(event.start_time) }} -
|
||||
@@ -61,7 +61,7 @@
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 mt-auto">
|
||||
<div class="flex items-center gap-x-2 mt-auto">
|
||||
<Button
|
||||
v-if="certificate.name"
|
||||
@click="openCertificate(certificate)"
|
||||
@@ -86,7 +86,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs :tabs="tabs" as="div" v-model="tabIndex" class="border-l w-1/2">
|
||||
<Tabs :tabs="tabs" as="div" v-model="tabIndex" class="border-s w-1/2">
|
||||
<template #tab-panel="{ tab }">
|
||||
<div
|
||||
v-if="tab.label == 'Evaluation'"
|
||||
@@ -122,10 +122,13 @@
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="flex flex-col space-y-4 p-5">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="certificate.published"
|
||||
:label="__('Published')"
|
||||
:description="
|
||||
__('Make this certificate visible to the participant.')
|
||||
"
|
||||
:disabled="!userIsEvaluator()"
|
||||
/>
|
||||
<Link
|
||||
@@ -169,6 +172,7 @@ import {
|
||||
Button,
|
||||
FormControl,
|
||||
createResource,
|
||||
Switch,
|
||||
Tabs,
|
||||
Tooltip,
|
||||
Textarea,
|
||||
@@ -243,7 +247,7 @@ const evaluationResource = createResource({
|
||||
member: props.event.member,
|
||||
course: props.event.course,
|
||||
batch_name: props.event.batch_name,
|
||||
date: props.event.date,
|
||||
date_value: props.event.date,
|
||||
start_time: props.event.start_time,
|
||||
end_time: props.event.end_time,
|
||||
status: evaluation.status,
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: '4xl',
|
||||
size: '5xl',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div class="p-5 min-h-[300px]">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Training Feedback') }}
|
||||
</div>
|
||||
<ListView
|
||||
@@ -19,10 +19,17 @@
|
||||
rowHeight: 'h-16',
|
||||
selectable: false,
|
||||
}"
|
||||
class="border rounded-lg py-2 px-3"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
></ListHeader>
|
||||
class="mb-2 grid items-center rounded bg-surface-white border-b rounded-none !px-0"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in feedbackColumns">
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon :name="item.icon?.toString()" class="h-4 w-4" />
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow
|
||||
:row="row"
|
||||
@@ -41,7 +48,7 @@
|
||||
class="flex"
|
||||
:image="row['member_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
size="xl"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -63,9 +70,11 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Dialog,
|
||||
ListView,
|
||||
Avatar,
|
||||
FeatherIcon,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
@@ -89,27 +98,43 @@ const feedbackColumns = computed(() => {
|
||||
label: 'Member',
|
||||
key: 'member_name',
|
||||
width: '10rem',
|
||||
align: 'left',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
label: 'Feedback',
|
||||
key: 'feedback',
|
||||
width: '15rem',
|
||||
align: 'left',
|
||||
icon: 'message-square',
|
||||
},
|
||||
{
|
||||
label: 'Content',
|
||||
key: 'content',
|
||||
width: '9rem',
|
||||
width: '10rem',
|
||||
align: 'center',
|
||||
icon: 'book',
|
||||
},
|
||||
{
|
||||
label: 'Instructors',
|
||||
key: 'instructors',
|
||||
width: '9rem',
|
||||
width: '10rem',
|
||||
align: 'center',
|
||||
icon: 'users',
|
||||
},
|
||||
{
|
||||
label: 'Value',
|
||||
key: 'value',
|
||||
width: '9rem',
|
||||
width: '10rem',
|
||||
align: 'center',
|
||||
icon: 'dollar-sign',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.feedback-list > button > div {
|
||||
padding: 0.2rem 0;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
</FileUploader>
|
||||
</div>
|
||||
<div v-else class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<div class="border rounded-md p-2 me-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-ink-gray-7" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
@click="redirectToProfile(participant.member_username)"
|
||||
class="grid grid-cols-2 items-center w-full text-base w-fit py-2"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Avatar
|
||||
:image="participant.member_image"
|
||||
:label="participant.member_name"
|
||||
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-20 text-right">
|
||||
<div class="grid grid-cols-3 gap-20 text-end">
|
||||
<div>
|
||||
{{ dayjs(participant.joined_at).format('HH:mm a') }}
|
||||
</div>
|
||||
|
||||
@@ -29,14 +29,12 @@
|
||||
:label="__('Date')"
|
||||
:required="true"
|
||||
/>
|
||||
<Tooltip :text="__('Duration of the live class in minutes')">
|
||||
<FormControl
|
||||
type="number"
|
||||
v-model="liveClass.duration"
|
||||
:label="__('Duration')"
|
||||
:required="true"
|
||||
/>
|
||||
</Tooltip>
|
||||
<FormControl
|
||||
type="number"
|
||||
v-model="liveClass.duration"
|
||||
:label="__('Duration (in minutes)')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<Tooltip
|
||||
@@ -67,6 +65,7 @@
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
v-if="props.conferencingProvider === 'Zoom'"
|
||||
v-model="liveClass.auto_recording"
|
||||
type="select"
|
||||
:options="getRecordingOptions()"
|
||||
@@ -84,16 +83,10 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Dialog,
|
||||
createResource,
|
||||
Tooltip,
|
||||
FormControl,
|
||||
Autocomplete,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { Dialog, createResource, Tooltip, FormControl, toast } from 'frappe-ui'
|
||||
import { reactive, inject, onMounted } from 'vue'
|
||||
import { getTimezones, getUserTimezone } from '@/utils/'
|
||||
import Autocomplete from '@/components/Controls/Autocomplete.vue'
|
||||
|
||||
const liveClasses = defineModel('reloadLiveClasses')
|
||||
const show = defineModel()
|
||||
@@ -105,10 +98,9 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
zoomAccount: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
zoomAccount: String,
|
||||
googleMeetAccount: String,
|
||||
conferencingProvider: String,
|
||||
})
|
||||
|
||||
let liveClass = reactive({
|
||||
@@ -165,8 +157,23 @@ const createLiveClass = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const createGoogleMeetLiveClass = createResource({
|
||||
url: 'lms.lms.doctype.lms_batch.lms_batch.create_google_meet_live_class',
|
||||
makeParams(values) {
|
||||
return {
|
||||
batch_name: values.batch,
|
||||
google_meet_account: props.googleMeetAccount,
|
||||
...values,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const submitLiveClass = (close) => {
|
||||
return createLiveClass.submit(liveClass, {
|
||||
const resource =
|
||||
props.conferencingProvider === 'Google Meet'
|
||||
? createGoogleMeetLiveClass
|
||||
: createLiveClass
|
||||
return resource.submit(liveClass, {
|
||||
validate() {
|
||||
validateFormFields()
|
||||
},
|
||||
@@ -177,6 +184,7 @@ const submitLiveClass = (close) => {
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Add New Member'),
|
||||
size: 'lg',
|
||||
actions: [
|
||||
{
|
||||
label: __('Add'),
|
||||
variant: 'solid',
|
||||
loading: submitting,
|
||||
onClick: ({ close }: any) => addMember(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
v-model="member.email"
|
||||
:label="__('Email')"
|
||||
placeholder="jane@doe.com"
|
||||
type="email"
|
||||
:required="true"
|
||||
@keyup.enter="addMember()"
|
||||
/>
|
||||
<div class="flex items-center gap-3">
|
||||
<FormControl
|
||||
v-model="member.first_name"
|
||||
:label="__('First Name')"
|
||||
placeholder="Jane"
|
||||
type="text"
|
||||
class="w-full"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="member.last_name"
|
||||
:label="__('Last Name')"
|
||||
placeholder="Doe"
|
||||
type="text"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ __('Roles') }}
|
||||
</div>
|
||||
<div class="grid md:grid-cols-2 gap-x-6 gap-y-3">
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Student')"
|
||||
v-model="roles.lms_student"
|
||||
/>
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Course Creator')"
|
||||
v-model="roles.course_creator"
|
||||
/>
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Evaluator')"
|
||||
v-model="roles.batch_evaluator"
|
||||
/>
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Moderator')"
|
||||
v-model="roles.moderator"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { call, Dialog, FormControl, toast, Switch } from 'frappe-ui'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { cleanError } from '@/utils'
|
||||
|
||||
const show = defineModel<boolean>({ default: false })
|
||||
const submitting = ref(false)
|
||||
|
||||
const props = defineProps<{
|
||||
defaultRoles?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: [user: any]
|
||||
}>()
|
||||
|
||||
const ROLE_MAP: Record<string, string> = {
|
||||
moderator: 'Moderator',
|
||||
course_creator: 'Course Creator',
|
||||
batch_evaluator: 'Batch Evaluator',
|
||||
lms_student: 'LMS Student',
|
||||
}
|
||||
|
||||
const member = reactive({
|
||||
email: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
})
|
||||
|
||||
const roles = reactive({
|
||||
moderator: false,
|
||||
course_creator: false,
|
||||
batch_evaluator: false,
|
||||
lms_student: false,
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
member.email = ''
|
||||
member.first_name = ''
|
||||
member.last_name = ''
|
||||
applyDefaultRoles()
|
||||
}
|
||||
|
||||
const applyDefaultRoles = () => {
|
||||
roles.moderator = props.defaultRoles?.includes('moderator') ?? false
|
||||
roles.course_creator = props.defaultRoles?.includes('course_creator') ?? false
|
||||
roles.batch_evaluator =
|
||||
props.defaultRoles?.includes('batch_evaluator') ?? false
|
||||
roles.lms_student = props.defaultRoles?.includes('lms_student') ?? false
|
||||
}
|
||||
|
||||
watch(show, (isOpen) => {
|
||||
if (isOpen) {
|
||||
resetForm()
|
||||
}
|
||||
})
|
||||
|
||||
const assignRoles = async (userEmail: string) => {
|
||||
const selectedRoles = Object.entries(roles).filter(([_, checked]) => checked)
|
||||
|
||||
for (const [key, _] of selectedRoles) {
|
||||
await call('lms.lms.api.save_role', {
|
||||
user: userEmail,
|
||||
role: ROLE_MAP[key],
|
||||
value: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const addMember = async (close?: () => void) => {
|
||||
if (!member.email?.trim()) {
|
||||
toast.error(__('Email is required'))
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const user = await call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'User',
|
||||
email: member.email.trim(),
|
||||
first_name: member.first_name.trim() || undefined,
|
||||
last_name: member.last_name.trim() || undefined,
|
||||
},
|
||||
})
|
||||
|
||||
await assignRoles(user.name)
|
||||
|
||||
toast.success(__('Member added successfully'))
|
||||
emit('created', user)
|
||||
resetForm()
|
||||
close?.()
|
||||
} catch (err: any) {
|
||||
toast.error(cleanError(err.messages?.[0]) || __('Unable to add member'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: '5xl',
|
||||
size: '3xl',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
@@ -10,17 +10,14 @@
|
||||
<div class="text-lg font-semibold text-ink-gray-9 mb-5">
|
||||
{{ __(props.title) }}
|
||||
</div>
|
||||
<div
|
||||
<Switch
|
||||
v-if="!editMode"
|
||||
class="flex items-center text-xs text-ink-gray-7 space-x-5"
|
||||
>
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Choose an existing question')"
|
||||
v-model="chooseFromExisting"
|
||||
class="!p-0"
|
||||
/>
|
||||
</div>
|
||||
size="sm"
|
||||
:label="__('Choose an existing question')"
|
||||
:description="__('Select from questions you have already created')"
|
||||
v-model="chooseFromExisting"
|
||||
class="!p-0"
|
||||
/>
|
||||
<div v-if="!chooseFromExisting || editMode">
|
||||
<div>
|
||||
<label class="block text-xs text-ink-gray-5 mb-1">
|
||||
@@ -75,10 +72,11 @@
|
||||
:label="__('Explanation')"
|
||||
v-model="question[`explanation_${n}`]"
|
||||
/>
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Correct Answer')"
|
||||
:description="__('Mark this option as a correct answer.')"
|
||||
v-model="question[`is_correct_${n}`]"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,7 +105,7 @@
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-end space-x-2 mt-5">
|
||||
<div class="flex items-center justify-end gap-x-2 mt-5">
|
||||
<Button variant="solid" @click="submitQuestion()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
@@ -164,7 +162,7 @@ populateFields()
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: __('Add a new question'),
|
||||
default: __('Add new question'),
|
||||
},
|
||||
questionDetail: {
|
||||
type: [Object, null],
|
||||
|
||||
@@ -44,14 +44,14 @@
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in columns">
|
||||
<template #prefix="{ item }">
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="h-4 w-4 stroke-1.5 ml-4"
|
||||
class="h-4 w-4 stroke-1.5 ms-4"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Enroll a Student'),
|
||||
size: 'sm',
|
||||
size: 'lg',
|
||||
actions: [
|
||||
{
|
||||
label: 'Submit',
|
||||
@@ -51,8 +51,6 @@ import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import { openSettings } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const students = defineModel('reloadStudents')
|
||||
const batchModal = defineModel('batchModal')
|
||||
const student = ref(null)
|
||||
const payment = ref(null)
|
||||
const user = inject('$user')
|
||||
@@ -61,33 +59,37 @@ const show = defineModel()
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
students: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const addStudent = (close) => {
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'LMS Batch Enrollment',
|
||||
batch: props.batch,
|
||||
props.students.insert.submit(
|
||||
{
|
||||
member: student.value,
|
||||
payment: payment.value,
|
||||
batch: props.batch.data?.name,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
if (user.data?.is_system_manager)
|
||||
updateOnboardingStep('add_batch_student')
|
||||
{
|
||||
onSuccess() {
|
||||
if (user.data?.is_system_manager)
|
||||
updateOnboardingStep('add_batch_student')
|
||||
|
||||
students.value.reload()
|
||||
batchModal.value.reload()
|
||||
student.value = null
|
||||
payment.value = null
|
||||
close()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
})
|
||||
student.value = null
|
||||
payment.value = null
|
||||
props.batch.reload()
|
||||
close()
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: '4xl',
|
||||
title: __('Video Statistics for {0}').format(lessonTitle),
|
||||
title: __('Video Statistics'),
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
@@ -18,20 +18,25 @@
|
||||
<!-- <FormControl
|
||||
v-model="searchText"
|
||||
:placeholder="__('Search by Member')"
|
||||
class="mt-2 mr-5 w-[25%]"
|
||||
class="mt-2 me-5 w-[25%]"
|
||||
/> -->
|
||||
</div>
|
||||
<div v-if="currentTab" class="mt-4">
|
||||
<div
|
||||
v-if="currentTab"
|
||||
:class="{
|
||||
'mt-5': tabs.length > 1,
|
||||
}"
|
||||
>
|
||||
<div class="grid grid-cols-[55%,40%] gap-5">
|
||||
<div
|
||||
class="space-y-5 border rounded-md p-2 pt-4 h-[70vh] overflow-y-auto"
|
||||
class="space-y-5 border rounded-md p-2 pt-4 max-h-[70vh] overflow-y-auto"
|
||||
>
|
||||
<div class="grid grid-cols-[70%,30%] text-sm text-ink-gray-5">
|
||||
<div class="px-4">
|
||||
{{ __('Member') }}
|
||||
</div>
|
||||
<div class="text-center">
|
||||
{{ __('Watch Time') }}
|
||||
{{ __('Watch Time (mins)') }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -45,7 +50,7 @@
|
||||
}"
|
||||
>
|
||||
<div class="grid grid-cols-[70%,30%] items-center">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Avatar
|
||||
:image="row.member_image"
|
||||
:label="row.member_name"
|
||||
@@ -68,15 +73,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: __('Average Watch Time'),
|
||||
value: averageWatchTime,
|
||||
}"
|
||||
<NumberChartGraph
|
||||
:title="__('Average Watch Time (mins)')"
|
||||
:value="averageWatchTime"
|
||||
/>
|
||||
<div v-if="isPlyrSource">
|
||||
<div class="video-player" :src="currentTab"></div>
|
||||
<div
|
||||
class="video-player"
|
||||
:data-plyr-provider="provider"
|
||||
:src="currentTab"
|
||||
></div>
|
||||
</div>
|
||||
<VideoBlock v-else :file="currentTab" />
|
||||
</div>
|
||||
@@ -101,6 +107,7 @@ import {
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { enablePlyr, formatTimestamp } from '@/utils'
|
||||
import VideoBlock from '@/components/VideoBlock.vue'
|
||||
import NumberChartGraph from '@/components/NumberChartGraph.vue'
|
||||
|
||||
const show = defineModel<boolean | undefined>()
|
||||
const currentTab = ref<string>('')
|
||||
@@ -171,7 +178,7 @@ watch(show, () => {
|
||||
|
||||
const statisticsData = computed(() => {
|
||||
const grouped = <Record<string, any[]>>{}
|
||||
statistics.data.forEach((item: { source: string }) => {
|
||||
statistics.data?.forEach((item: { source: string }) => {
|
||||
if (!grouped[item.source]) {
|
||||
grouped[item.source] = []
|
||||
}
|
||||
|
||||
@@ -18,10 +18,13 @@
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="mb-4">
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="account.enabled"
|
||||
:label="__('Enabled')"
|
||||
type="checkbox"
|
||||
:description="
|
||||
__('Activate this Zoom account for scheduling meetings.')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
@@ -61,7 +64,7 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { call, Dialog, FormControl, toast } from 'frappe-ui'
|
||||
import { call, Dialog, FormControl, Switch, toast } from 'frappe-ui'
|
||||
import { inject, reactive, watch } from 'vue'
|
||||
import { User } from '@/components/Settings/types'
|
||||
import { openSettings, cleanError } from '@/utils'
|
||||
@@ -109,16 +112,14 @@ const account = reactive({
|
||||
client_secret: '',
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
accountID: {
|
||||
type: String,
|
||||
default: 'new',
|
||||
},
|
||||
})
|
||||
const props = defineProps<{
|
||||
accountID: string | null
|
||||
}>()
|
||||
|
||||
watch(
|
||||
() => props.accountID,
|
||||
(val) => {
|
||||
console.log(props.accountID)
|
||||
if (val === 'new') {
|
||||
account.name = ''
|
||||
account.enabled = false
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="text-base border rounded-md w-1/3 mx-auto my-32">
|
||||
<div class="border-b px-5 py-3 font-medium text-ink-gray-9">
|
||||
<span
|
||||
class="inline-flex items-center before:bg-surface-red-5 before:w-2 before:h-2 before:rounded-md before:mr-2"
|
||||
class="inline-flex items-center before:bg-surface-red-5 before:w-2 before:h-2 before:rounded-md before:me-2"
|
||||
></span>
|
||||
{{ __(title) }}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
:style="{
|
||||
display: top > 0 ? 'block' : 'none',
|
||||
top: top + 'px',
|
||||
left: left + 'px',
|
||||
insetInlineStart: left + 'px',
|
||||
}"
|
||||
>
|
||||
<div class="space-y-2 py-2">
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="">
|
||||
<div
|
||||
v-for="color in colors"
|
||||
class="flex items-center space-x-2 px-3 py-2 cursor-pointer hover:bg-surface-gray-2"
|
||||
class="flex items-center gap-x-2 px-3 py-2 cursor-pointer hover:bg-surface-gray-2"
|
||||
@click="saveHighLight(color)"
|
||||
>
|
||||
<span
|
||||
@@ -32,7 +32,7 @@
|
||||
<div class="border-t">
|
||||
<div
|
||||
@click="addToNotes()"
|
||||
class="flex items-center space-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
|
||||
class="flex items-center gap-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
|
||||
>
|
||||
<NotepadText class="size-3 stroke-1.5" />
|
||||
<span>
|
||||
@@ -42,7 +42,7 @@
|
||||
<div
|
||||
v-if="highlightExists()"
|
||||
@click="deleteHighlight"
|
||||
class="flex items-center space-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
|
||||
class="flex items-center gap-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
|
||||
>
|
||||
<Trash2 class="size-3 stroke-1.5" />
|
||||
<span>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="text-ink-gray-5">
|
||||
{{ __(title) }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<slot name="prefix" />
|
||||
<div class="font-semibold text-ink-gray-9 text-2xl">
|
||||
{{ value }}
|
||||
|
||||
+369
-101
@@ -1,59 +1,75 @@
|
||||
<template>
|
||||
<div v-if="quiz.data">
|
||||
<div
|
||||
class="bg-surface-blue-2 space-y-2 py-2 px-3 mb-4 rounded-md text-sm text-ink-blue-2 leading-5"
|
||||
class="bg-surface-blue-2 text-ink-blue-3 space-y-2 p-3 mb-4 rounded-lg leading-5"
|
||||
>
|
||||
<div v-if="inVideo">
|
||||
{{ __('You will have to complete the quiz to continue the video') }}
|
||||
</div>
|
||||
<div class="leading-5">
|
||||
{{
|
||||
__('This quiz consists of {0} questions.').format(questions.length)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data?.duration" class="leading-5">
|
||||
<div class="font-medium">
|
||||
{{
|
||||
__(
|
||||
'Please ensure that you complete all the questions in {0} minutes.'
|
||||
).format(quiz.data.duration)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data?.duration" class="leading-5">
|
||||
{{
|
||||
__(
|
||||
'If you fail to do so, the quiz will be automatically submitted when the timer ends.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data.passing_percentage" class="leading-relaxed">
|
||||
{{
|
||||
__(
|
||||
'You will have to get {0}% correct answers in order to pass the quiz.'
|
||||
).format(quiz.data.passing_percentage)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data.max_attempts" class="leading-5">
|
||||
{{
|
||||
__('You can attempt this quiz {0}.').format(
|
||||
quiz.data.max_attempts == 1
|
||||
? '1 time'
|
||||
: `${quiz.data.max_attempts} times`
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data.enable_negative_marking" class="leading-5">
|
||||
{{
|
||||
__(
|
||||
'If you answer incorrectly, {0} {1} will be deducted from your score for each incorrect answer.'
|
||||
).format(
|
||||
quiz.data.marks_to_cut,
|
||||
quiz.data.marks_to_cut == 1 ? 'mark' : 'marks'
|
||||
'Please read the following instructions carefully before starting the quiz'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<ol class="list-decimal list-inside space-y-2">
|
||||
<li v-if="inVideo">
|
||||
{{ __('You will have to complete the quiz to continue the video') }}
|
||||
</li>
|
||||
<li>
|
||||
{{
|
||||
__(
|
||||
'Do not refresh the page or close this window. If you do, the quiz will be submitted automatically.'
|
||||
)
|
||||
}}
|
||||
</li>
|
||||
<li>
|
||||
{{
|
||||
__('This quiz consists of {0} questions.').format(questions.length)
|
||||
}}
|
||||
</li>
|
||||
<li v-if="quiz.data?.duration">
|
||||
{{
|
||||
__(
|
||||
'Please ensure that you complete all the questions in {0} minutes.'
|
||||
).format(quiz.data.duration)
|
||||
}}
|
||||
</li>
|
||||
<li v-if="quiz.data?.duration">
|
||||
{{
|
||||
__(
|
||||
'If you fail to do so, the quiz will be automatically submitted when the timer ends.'
|
||||
)
|
||||
}}
|
||||
</li>
|
||||
<li v-if="quiz.data.passing_percentage">
|
||||
{{
|
||||
__(
|
||||
'You will have to get {0}% correct answers in order to pass the quiz.'
|
||||
).format(quiz.data.passing_percentage)
|
||||
}}
|
||||
</li>
|
||||
<li v-if="quiz.data.max_attempts">
|
||||
{{
|
||||
__('You can attempt this quiz {0}.').format(
|
||||
quiz.data.max_attempts == 1
|
||||
? '1 time'
|
||||
: `${quiz.data.max_attempts} times`
|
||||
)
|
||||
}}
|
||||
</li>
|
||||
<li v-if="quiz.data.enable_negative_marking">
|
||||
{{
|
||||
__(
|
||||
'If you answer incorrectly, {0} {1} will be deducted from your score for each incorrect answer.'
|
||||
).format(
|
||||
quiz.data.marks_to_cut,
|
||||
quiz.data.marks_to_cut == 1 ? 'mark' : 'marks'
|
||||
)
|
||||
}}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div v-if="quiz.data.duration" class="flex flex-col space-x-1 my-4">
|
||||
<div v-if="quiz.data.duration" class="flex flex-col gap-x-1 my-4 px-2">
|
||||
<div class="mb-2">
|
||||
<span class="text-ink-gray-9"> {{ __('Time') }}: </span>
|
||||
<span class="font-semibold text-ink-gray-9">
|
||||
@@ -68,7 +84,7 @@
|
||||
<div class="font-semibold text-lg text-ink-gray-9">
|
||||
{{ quiz.data.title }}
|
||||
</div>
|
||||
<div class="flex items-center justify-center space-x-2 mt-4">
|
||||
<div class="flex items-center justify-center gap-x-2 mt-4">
|
||||
<Button
|
||||
v-if="
|
||||
!quiz.data.max_attempts ||
|
||||
@@ -104,16 +120,12 @@
|
||||
<div v-for="(question, qtidx) in questions">
|
||||
<div
|
||||
v-if="qtidx == activeQuestion - 1 && questionDetails.data"
|
||||
class="border rounded-md p-5"
|
||||
class="border rounded-lg p-5"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
<span class="mr-2">
|
||||
{{ __('Question {0}').format(activeQuestion) }}:
|
||||
</span>
|
||||
<span>
|
||||
{{ getInstructions(questionDetails.data) }}
|
||||
</span>
|
||||
{{ __('Question {0}').format(activeQuestion) }} -
|
||||
{{ getInstructions(questionDetails.data) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-9 text-sm font-semibold item-left">
|
||||
{{ question.marks }}
|
||||
@@ -135,6 +147,7 @@
|
||||
:name="encodeURIComponent(questionDetails.data.question)"
|
||||
class="w-3.5 h-3.5 text-ink-gray-9 focus:ring-outline-gray-modals"
|
||||
@change="markAnswer(index)"
|
||||
:checked="selectedOptions[index - 1]"
|
||||
/>
|
||||
|
||||
<input
|
||||
@@ -143,6 +156,7 @@
|
||||
:name="encodeURIComponent(questionDetails.data.question)"
|
||||
class="w-3.5 h-3.5 text-ink-gray-9 rounded-sm focus:ring-outline-gray-modals"
|
||||
@change="markAnswer(index)"
|
||||
:checked="selectedOptions[index - 1]"
|
||||
/>
|
||||
<div
|
||||
v-else-if="quiz.data.show_answers"
|
||||
@@ -165,7 +179,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="ml-2 text-ink-gray-9"
|
||||
class="ms-2 text-ink-gray-9"
|
||||
v-html="questionDetails.data[`option_${index}`]"
|
||||
>
|
||||
</span>
|
||||
@@ -188,12 +202,12 @@
|
||||
<div v-if="showAnswers.length">
|
||||
<Badge v-if="showAnswers[0]" :label="__('Correct')" theme="green">
|
||||
<template #prefix>
|
||||
<CheckCircle class="w-4 h-4 text-ink-green-2 mr-1" />
|
||||
<CheckCircle class="w-4 h-4 text-ink-green-2 me-1" />
|
||||
</template>
|
||||
</Badge>
|
||||
<Badge v-else theme="red" :label="__('Incorrect')">
|
||||
<template #prefix>
|
||||
<XCircle class="w-4 h-4 text-ink-red-3 mr-1" />
|
||||
<XCircle class="w-4 h-4 text-ink-red-3 me-1" />
|
||||
</template>
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -208,14 +222,56 @@
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{
|
||||
__('Question {0} of {1}').format(
|
||||
activeQuestion,
|
||||
questions.length
|
||||
)
|
||||
}}
|
||||
<div class="flex items-center justify-between mt-8">
|
||||
<Checkbox
|
||||
v-if="!quiz.data.show_answers"
|
||||
:label="__('Mark for review')"
|
||||
:model-value="reviewQuestions.includes(activeQuestion) ? 1 : 0"
|
||||
@change="markForReview($event, activeQuestion)"
|
||||
/>
|
||||
<div
|
||||
v-if="!quiz.data.show_answers"
|
||||
class="flex items-center gap-x-2"
|
||||
>
|
||||
<Button
|
||||
@click="switchQuestion(activeQuestion - 1)"
|
||||
:disabled="activeQuestion == 1"
|
||||
class="rounded-full"
|
||||
>
|
||||
<template #icon>
|
||||
<ChevronLeft class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<span
|
||||
v-for="item in paginationWindow"
|
||||
:key="item"
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center text-sm"
|
||||
:class="{
|
||||
'cursor-pointer': item !== '...',
|
||||
'bg-surface-gray-4 border border-outline-gray-5 font-medium':
|
||||
activeQuestion == item,
|
||||
'text-ink-gray-5': item === '...',
|
||||
'bg-surface-blue-3 text-ink-white':
|
||||
attemptedQuestions.includes(item) && activeQuestion != item,
|
||||
'bg-surface-gray-3 text-ink-gray-6':
|
||||
activeQuestion != item &&
|
||||
item !== '...' &&
|
||||
!attemptedQuestions.includes(item),
|
||||
}"
|
||||
@click="item !== '...' && switchQuestion(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
@click="switchQuestion(activeQuestion + 1)"
|
||||
:disabled="activeQuestion == questions.length"
|
||||
class="rounded-full"
|
||||
>
|
||||
<template #icon>
|
||||
<ChevronRight class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
v-if="
|
||||
@@ -223,6 +279,7 @@
|
||||
!showAnswers.length &&
|
||||
questionDetails.data.type != 'Open Ended'
|
||||
"
|
||||
class="ms-auto"
|
||||
@click="checkAnswer()"
|
||||
>
|
||||
<span>
|
||||
@@ -230,14 +287,22 @@
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="activeQuestion != questions.length"
|
||||
v-else-if="
|
||||
activeQuestion != questions.length && quiz.data.show_answers
|
||||
"
|
||||
@click="nextQuestion()"
|
||||
class="ms-auto"
|
||||
>
|
||||
<span>
|
||||
{{ __('Next') }}
|
||||
</span>
|
||||
</Button>
|
||||
<Button v-else @click="submitQuiz()">
|
||||
<Button
|
||||
variant="solid"
|
||||
v-else
|
||||
@click="handleSubmitClick()"
|
||||
class="ms-auto"
|
||||
>
|
||||
<span>
|
||||
{{ __('Submit') }}
|
||||
</span>
|
||||
@@ -245,8 +310,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="reviewQuestions.length" class="border rounded-lg p-4 mt-4">
|
||||
<div class="font-semibold">
|
||||
{{ __('Questions marked for review') }}
|
||||
</div>
|
||||
<div class="flex items-center gap-x-2 mt-2">
|
||||
<div
|
||||
v-for="index in reviewQuestions"
|
||||
@click="switchQuestion(index)"
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center text-sm cursor-pointer bg-surface-gray-3"
|
||||
>
|
||||
{{ index }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="border rounded-md p-20 text-center space-y-2">
|
||||
<div v-else class="border rounded-lg p-20 text-center space-y-2">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Quiz Summary') }}
|
||||
</div>
|
||||
@@ -271,7 +350,7 @@
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<div class="flex gap-x-2">
|
||||
<Button
|
||||
@click="resetQuiz()"
|
||||
class="mt-2"
|
||||
@@ -310,30 +389,96 @@
|
||||
</ListView>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
v-model="showSubmissionConfirmation"
|
||||
:options="{
|
||||
title: __('Are you sure you want to submit the quiz?'),
|
||||
actions: [
|
||||
{
|
||||
size: 'sm',
|
||||
label: __('Submit'),
|
||||
variant: 'solid',
|
||||
onClick() {
|
||||
submitQuiz()
|
||||
showSubmissionConfirmation = false
|
||||
},
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="border border-outline-gray-modals rounded-lg text-base">
|
||||
<div class="divide-y divide-outline-gray-modals">
|
||||
<div class="grid grid-cols-2 divide-x divide-outline-gray-modals">
|
||||
<div class="p-2">
|
||||
{{ __('Total Questions') }}
|
||||
</div>
|
||||
<div class="p-2">
|
||||
{{ questions.length }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 divide-x divide-outline-gray-modals">
|
||||
<div class="p-2">
|
||||
{{ __('Attempted Questions') }}
|
||||
</div>
|
||||
<div class="p-2">
|
||||
{{ attemptedQuestions.length }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 divide-x divide-outline-gray-modals">
|
||||
<div class="p-2">
|
||||
{{ __('Unattempted Questions') }}
|
||||
</div>
|
||||
<div class="p-2">
|
||||
{{ questions.length - attemptedQuestions.length }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
call,
|
||||
Checkbox,
|
||||
createResource,
|
||||
Dialog,
|
||||
ListView,
|
||||
TextEditor,
|
||||
FormControl,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { ref, watch, reactive, inject, computed } from 'vue'
|
||||
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
reactive,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue'
|
||||
import {
|
||||
CheckCircle,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
XCircle,
|
||||
MinusCircle,
|
||||
} from 'lucide-vue-next'
|
||||
import { timeAgo } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const activeQuestion = ref(0)
|
||||
const currentQuestion = ref('')
|
||||
const selectedOptions = reactive([0, 0, 0, 0])
|
||||
const selectedOptions = ref([0, 0, 0, 0])
|
||||
const showAnswers = reactive([])
|
||||
let questions = reactive([])
|
||||
const attemptedQuestions = ref([])
|
||||
const reviewQuestions = ref([])
|
||||
const showSubmissionConfirmation = ref(false)
|
||||
const possibleAnswer = ref(null)
|
||||
const timer = ref(0)
|
||||
let timerInterval = null
|
||||
@@ -353,6 +498,40 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('pagehide', handlePageHide)
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('pagehide', handlePageHide)
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
const handlePageHide = () => {
|
||||
if (activeQuestion.value > 0 && !quizSubmission.data) {
|
||||
const params = new URLSearchParams({
|
||||
quiz: quiz.data.name,
|
||||
results: localStorage.getItem(quiz.data.title),
|
||||
})
|
||||
|
||||
navigator.sendBeacon(
|
||||
'/api/method/lms.lms.doctype.lms_quiz.lms_quiz.submit_quiz?' +
|
||||
params.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBeforeUnload = (event) => {
|
||||
if (activeQuestion.value > 0 && !quizSubmission.data) {
|
||||
if (attemptedQuestions.value.length) {
|
||||
switchQuestion(activeQuestion.value)
|
||||
}
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
}
|
||||
}
|
||||
|
||||
const quiz = createResource({
|
||||
url: 'frappe.client.get',
|
||||
makeParams(values) {
|
||||
@@ -465,7 +644,7 @@ watch(
|
||||
)
|
||||
|
||||
const quizSubmission = createResource({
|
||||
url: 'lms.lms.doctype.lms_quiz.lms_quiz.quiz_summary',
|
||||
url: 'lms.lms.doctype.lms_quiz.lms_quiz.submit_quiz',
|
||||
makeParams(values) {
|
||||
return {
|
||||
quiz: quiz.data.name,
|
||||
@@ -486,10 +665,58 @@ const questionDetails = createResource({
|
||||
watch(activeQuestion, (value) => {
|
||||
if (value > 0) {
|
||||
currentQuestion.value = quiz.data.questions[value - 1].question
|
||||
questionDetails.reload()
|
||||
questionDetails.reload(
|
||||
{},
|
||||
{
|
||||
onSuccess() {
|
||||
if (!quiz.data.show_answers) {
|
||||
loadSavedAnswers()
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const switchQuestion = (questionNumber) => {
|
||||
let answers = getAnswers()
|
||||
if (answers.length) {
|
||||
if (!attemptedQuestions.value.includes(activeQuestion.value)) {
|
||||
attemptedQuestions.value.push(activeQuestion.value)
|
||||
}
|
||||
addToLocalStorage()
|
||||
resetQuestion()
|
||||
}
|
||||
|
||||
if (questionNumber < 1 || questionNumber > questions.length) return
|
||||
activeQuestion.value = questionNumber
|
||||
}
|
||||
|
||||
const loadSavedAnswers = () => {
|
||||
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
|
||||
if (quizData) {
|
||||
let localQuestion = quizData.find(
|
||||
(q) => q.question_name == currentQuestion.value
|
||||
)
|
||||
if (localQuestion) {
|
||||
let localAnswers = localQuestion.answer
|
||||
if (localAnswers.length) {
|
||||
if (questionDetails.data.type == 'Choices') {
|
||||
localAnswers.forEach((answer) => {
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
if (questionDetails.data[`option_${i}`] == answer) {
|
||||
selectedOptions.value[i - 1] = 1
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
possibleAnswer.value = localAnswers[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.quizName,
|
||||
(newName) => {
|
||||
@@ -507,17 +734,20 @@ const startQuiz = () => {
|
||||
|
||||
const markAnswer = (index) => {
|
||||
if (!questionDetails.data.multiple)
|
||||
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
|
||||
selectedOptions[index - 1] = selectedOptions[index - 1] ? 0 : 1
|
||||
selectedOptions.value.splice(
|
||||
0,
|
||||
selectedOptions.value.length,
|
||||
...[0, 0, 0, 0]
|
||||
)
|
||||
selectedOptions.value[index - 1] = selectedOptions.value[index - 1] ? 0 : 1
|
||||
}
|
||||
|
||||
const getAnswers = () => {
|
||||
let answers = []
|
||||
const type = questionDetails.data.type
|
||||
|
||||
if (type == 'Choices') {
|
||||
selectedOptions.forEach((value, index) => {
|
||||
if (selectedOptions[index])
|
||||
selectedOptions.value.forEach((value, index) => {
|
||||
if (selectedOptions.value[index])
|
||||
answers.push(questionDetails.data[`option_${index + 1}`])
|
||||
})
|
||||
} else {
|
||||
@@ -538,14 +768,14 @@ const checkAnswer = () => {
|
||||
url: 'lms.lms.doctype.lms_quiz.lms_quiz.check_answer',
|
||||
params: {
|
||||
question: currentQuestion.value,
|
||||
type: questionDetails.data.type,
|
||||
question_type: questionDetails.data.type,
|
||||
answers: JSON.stringify(answers),
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
let type = questionDetails.data.type
|
||||
if (type == 'Choices') {
|
||||
selectedOptions.forEach((option, index) => {
|
||||
selectedOptions.value.forEach((option, index) => {
|
||||
if (option) {
|
||||
showAnswers[index] = option && data[index]
|
||||
} else if (data[index] == 2) {
|
||||
@@ -569,17 +799,15 @@ const addToLocalStorage = () => {
|
||||
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
|
||||
let questionData = {
|
||||
question_name: currentQuestion.value,
|
||||
answer: getAnswers().join(),
|
||||
is_correct: showAnswers.filter((answer) => {
|
||||
return answer != undefined
|
||||
}),
|
||||
answer: getAnswers(),
|
||||
}
|
||||
|
||||
if (quizData) {
|
||||
let existingQuestion = quizData.find(
|
||||
(q) => q.question_name == questionData.question_name
|
||||
)
|
||||
if (!existingQuestion) {
|
||||
if (existingQuestion) {
|
||||
existingQuestion.answer = questionData.answer
|
||||
} else {
|
||||
quizData.push(questionData)
|
||||
}
|
||||
} else {
|
||||
@@ -589,18 +817,15 @@ const addToLocalStorage = () => {
|
||||
}
|
||||
|
||||
const nextQuestion = () => {
|
||||
if (!quiz.data.show_answers && questionDetails.data?.type != 'Open Ended') {
|
||||
checkAnswer()
|
||||
} else {
|
||||
if (questionDetails.data?.type == 'Open Ended') addToLocalStorage()
|
||||
resetQuestion()
|
||||
}
|
||||
if (!quiz.data.show_answers) return
|
||||
if (questionDetails.data?.type == 'Open Ended') addToLocalStorage()
|
||||
resetQuestion()
|
||||
}
|
||||
|
||||
const resetQuestion = () => {
|
||||
if (activeQuestion.value == quiz.data.questions.length) return
|
||||
activeQuestion.value = activeQuestion.value + 1
|
||||
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
|
||||
selectedOptions.value.splice(0, selectedOptions.value.length, ...[0, 0, 0, 0])
|
||||
showAnswers.length = 0
|
||||
possibleAnswer.value = null
|
||||
}
|
||||
@@ -608,7 +833,6 @@ const resetQuestion = () => {
|
||||
const submitQuiz = () => {
|
||||
if (!quiz.data.show_answers) {
|
||||
if (questionDetails.data.type == 'Open Ended') addToLocalStorage()
|
||||
else checkAnswer()
|
||||
setTimeout(() => {
|
||||
createSubmission()
|
||||
}, 500)
|
||||
@@ -642,8 +866,10 @@ const createSubmission = () => {
|
||||
|
||||
const resetQuiz = () => {
|
||||
activeQuestion.value = 0
|
||||
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
|
||||
selectedOptions.value.splice(0, selectedOptions.value.length, ...[0, 0, 0, 0])
|
||||
showAnswers.length = 0
|
||||
possibleAnswer.value = null
|
||||
attemptedQuestions.value = []
|
||||
quizSubmission.reset()
|
||||
populateQuestions()
|
||||
setupTimer()
|
||||
@@ -672,6 +898,53 @@ const markLessonProgress = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitClick = () => {
|
||||
if (!quiz.data.show_answers) {
|
||||
if (attemptedQuestions.value.length) {
|
||||
switchQuestion(activeQuestion.value)
|
||||
}
|
||||
showSubmissionConfirmation.value = true
|
||||
} else {
|
||||
submitQuiz()
|
||||
}
|
||||
}
|
||||
|
||||
const paginationWindow = computed(() => {
|
||||
const total = questions.length
|
||||
const current = activeQuestion.value
|
||||
const pages = []
|
||||
const size = 5
|
||||
|
||||
let start = Math.floor((current - 1) / size) * size + 1
|
||||
let end = Math.min(start + size - 1, total)
|
||||
|
||||
if (start > 1) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
if (end < total) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
return pages
|
||||
})
|
||||
|
||||
const markForReview = (event, questionNumber) => {
|
||||
if (event.target.checked) {
|
||||
if (!reviewQuestions.value.includes(questionNumber)) {
|
||||
reviewQuestions.value.push(questionNumber)
|
||||
}
|
||||
} else {
|
||||
reviewQuestions.value = reviewQuestions.value.filter(
|
||||
(num) => num !== questionNumber
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const getSubmissionColumns = () => {
|
||||
return [
|
||||
{
|
||||
@@ -700,8 +973,3 @@ const getSubmissionColumns = () => {
|
||||
]
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
p {
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="text-base">
|
||||
<div class="flex items-center justify-between space-x-2 mb-5">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center justify-between gap-x-2 mb-5">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<ChevronLeft
|
||||
class="size-5 stroke-1.5 text-ink-gray-5 cursor-pointer"
|
||||
@click="
|
||||
@@ -34,7 +34,7 @@
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in columns">
|
||||
<template #prefix="{ item }">
|
||||
|
||||
@@ -9,11 +9,7 @@
|
||||
<template #body-content>
|
||||
<div class="grid grid-cols-2 gap-x-5">
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
v-model="badge.enabled"
|
||||
:label="__('Enabled')"
|
||||
type="checkbox"
|
||||
/>
|
||||
<Switch size="sm" v-model="badge.enabled" :label="__('Enabled')" />
|
||||
<FormControl
|
||||
v-model="badge.title"
|
||||
:label="__('Title')"
|
||||
@@ -41,10 +37,11 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="badge.grant_only_once"
|
||||
:label="__('Grant Only Once')"
|
||||
type="checkbox"
|
||||
:description="__('Each user can only receive this badge one time.')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="badge.event"
|
||||
@@ -73,7 +70,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="pb-5 float-right">
|
||||
<div class="pb-5 float-end">
|
||||
<Button variant="solid" @click="saveBadge(close)">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
@@ -82,7 +79,7 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, call, Dialog, FormControl, toast } from 'frappe-ui'
|
||||
import { Button, call, Dialog, FormControl, Switch, toast } from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { cleanError } from '@/utils'
|
||||
import type { Badges, Badge } from '@/components/Settings/types'
|
||||
@@ -206,7 +203,7 @@ const referenceDoctypeOptions = computed(() => {
|
||||
})
|
||||
|
||||
const eventOptions = computed(() => {
|
||||
let options = ['New', 'Value Change', 'Auto Assign']
|
||||
let options = ['New', 'Value Change', 'Manual Assignment']
|
||||
return options.map((event) => ({ label: __(event), value: event }))
|
||||
})
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="badges.data?.length" class="overflow-y-scroll">
|
||||
<div v-if="badges.data?.length" class="overflow-y-auto">
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="badges.data"
|
||||
@@ -32,7 +32,7 @@
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in columns" :key="item.key">
|
||||
<template #prefix="{ item }">
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex flex-col h-full text-p-base">
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold mb-1 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
<div class="space-y-2">
|
||||
<div class="font-semibold text-xl text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Badge
|
||||
v-if="isDirty"
|
||||
:label="__('Not Saved')"
|
||||
@@ -21,9 +26,6 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-ink-gray-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-y-auto">
|
||||
<SettingFields :sections="sections" :data="branding.data" />
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-5">
|
||||
<div class="flex items-center gap-x-5">
|
||||
<div
|
||||
class="flex items-center space-x-1 text-ink-amber-3 border border-outline-amber-1 bg-surface-amber-1 rounded-lg px-2 py-1"
|
||||
class="flex items-center gap-x-1 text-ink-amber-3 border border-outline-amber-1 bg-surface-amber-1 rounded-lg px-2 py-1"
|
||||
v-if="saving"
|
||||
>
|
||||
<LoadingIndicator class="size-2" />
|
||||
@@ -29,10 +29,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showForm"
|
||||
class="flex items-center justify-between my-4 space-x-2"
|
||||
>
|
||||
<div v-if="showForm" class="flex items-center justify-between my-4 gap-x-2">
|
||||
<FormControl
|
||||
ref="categoryInput"
|
||||
v-model="category"
|
||||
@@ -44,7 +41,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-scroll">
|
||||
<div class="overflow-y-auto">
|
||||
<div class="divide-y divide-outline-gray-modals space-y-2">
|
||||
<div
|
||||
v-for="(cat, index) in categories.data"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-col text-base h-full">
|
||||
<div class="flex items-center space-x-2 mb-8 -ml-1.5">
|
||||
<div class="flex items-center gap-x-2 mb-8 -ms-1.5">
|
||||
<ChevronLeft
|
||||
class="size-5 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||
@click="emit('updateStep', 'list')"
|
||||
@@ -11,10 +11,11 @@
|
||||
</div>
|
||||
<div class="space-y-4 overflow-y-auto">
|
||||
<div>
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="data.enabled"
|
||||
:label="__('Enabled')"
|
||||
type="checkbox"
|
||||
:description="__('Allow this coupon to be used for discounts.')"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
@@ -73,7 +74,7 @@
|
||||
<CouponItems ref="couponItems" :data="data" :coupons="coupons" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-auto space-x-2 ml-auto">
|
||||
<div class="mt-auto flex gap-x-2 items-center ms-auto">
|
||||
<Button variant="solid" @click="saveCoupon()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
@@ -81,7 +82,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, FormControl, toast } from 'frappe-ui'
|
||||
import { Button, FormControl, toast, Switch } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
import { ChevronLeft } from 'lucide-vue-next'
|
||||
import type { Coupon, Coupons } from './types'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="relative overflow-x-auto border rounded-md">
|
||||
<table class="w-full text-sm text-left text-ink-gray-5">
|
||||
<table class="w-full text-sm text-start text-ink-gray-5">
|
||||
<thead class="text-xs text-ink-gray-7 uppercase bg-surface-gray-2">
|
||||
<tr>
|
||||
<td scope="col" class="px-6 py-2">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
@@ -17,7 +17,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="coupons.data?.length" class="overflow-y-scroll">
|
||||
<div v-if="coupons.data?.length" class="overflow-y-auto">
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="coupons.data"
|
||||
@@ -31,7 +31,7 @@
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{{ __(description) }}
|
||||
</div> -->
|
||||
</div>
|
||||
<div class="flex items-center space-x-5">
|
||||
<div class="flex items-center gap-x-5">
|
||||
<Button @click="openTemplateForm('new')">
|
||||
<template #prefix>
|
||||
<Plus class="h-3 w-3 stroke-1.5" />
|
||||
@@ -18,7 +18,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="emailTemplates.data?.length" class="overflow-y-scroll">
|
||||
<div v-if="emailTemplates.data?.length" class="overflow-y-auto">
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="emailTemplates.data"
|
||||
@@ -31,14 +31,14 @@
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in columns">
|
||||
<template #prefix="{ item }">
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="h-4 w-4 stroke-1.5 ml-4"
|
||||
class="h-4 w-4 stroke-1.5 ms-4"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
|
||||
@@ -2,25 +2,57 @@
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2">
|
||||
<Button variant="solid" @click="() => (showForm = !showForm)">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
<div class="flex item-center gap-x-2">
|
||||
<Dropdown
|
||||
placement="right"
|
||||
side="bottom"
|
||||
:options="[
|
||||
{
|
||||
label: __('New Evaluator'),
|
||||
icon: 'user-plus',
|
||||
onClick() {
|
||||
showNewEvaluator = true
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __('Existing User'),
|
||||
icon: 'user-check',
|
||||
onClick() {
|
||||
showExistingUser = true
|
||||
},
|
||||
},
|
||||
]"
|
||||
>
|
||||
<template v-slot="{ open }">
|
||||
<Button variant="solid">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
<template #suffix>
|
||||
<ChevronDown
|
||||
:class="[
|
||||
'w-4 h-4 stroke-1.5 ms-1 transform transition-transform',
|
||||
open ? 'rotate-180' : '',
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pb-5">
|
||||
<FormControl
|
||||
v-if="evaluators.data?.length > 0 || search"
|
||||
v-model="search"
|
||||
:placeholder="__('Search')"
|
||||
type="text"
|
||||
@@ -31,7 +63,7 @@
|
||||
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
|
||||
</template>
|
||||
</FormControl>
|
||||
<div class="overflow-auto h-[60vh]">
|
||||
<div class="overflow-auto max-h-[60vh]">
|
||||
<div class="divide-y divide-outline-gray-modals">
|
||||
<div
|
||||
v-for="evaluator in evaluators.data"
|
||||
@@ -40,7 +72,7 @@
|
||||
>
|
||||
<div class="flex items-center justify-between group py-3">
|
||||
<div
|
||||
class="flex items-center space-x-3"
|
||||
class="flex items-center gap-x-3"
|
||||
@click="openProfile(evaluator.username)"
|
||||
>
|
||||
<Avatar
|
||||
@@ -70,11 +102,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="evaluators.length && hasNextPage"
|
||||
class="flex justify-center mt-4"
|
||||
>
|
||||
<Button @click="evaluators.reload()">
|
||||
<div v-if="evaluators.hasNextPage" class="flex justify-center mt-4">
|
||||
<Button @click="evaluators.next()">
|
||||
<template #prefix>
|
||||
<RefreshCw class="h-3 w-3 stroke-1.5" />
|
||||
</template>
|
||||
@@ -84,33 +113,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
v-model="showForm"
|
||||
:options="{
|
||||
size: 'xl',
|
||||
title: __('Add Evaluator'),
|
||||
actions: [{
|
||||
label: __('Add'),
|
||||
variant: 'solid',
|
||||
onClick({ close }: any) {
|
||||
addEvaluator(close)
|
||||
},
|
||||
}]
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div v-if="showForm" class="flex items-center">
|
||||
<FormControl
|
||||
v-model="email"
|
||||
:label="__('Email')"
|
||||
placeholder="jane@doe.com"
|
||||
type="email"
|
||||
class="w-full"
|
||||
@keydown.enter="addEvaluator"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
<AddEvaluatorModal v-model="showExistingUser" @added="evaluators.reload()" />
|
||||
<NewMemberModal
|
||||
v-model="showNewEvaluator"
|
||||
:defaultRoles="['batch_evaluator']"
|
||||
@created="onMemberCreated"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
@@ -118,18 +126,20 @@ import {
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
Dialog,
|
||||
Dropdown,
|
||||
FormControl,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { Plus, Search, Trash2, RefreshCw } from 'lucide-vue-next'
|
||||
import { Plus, Search, Trash2, RefreshCw, ChevronDown } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import NewMemberModal from '@/components/Modals/NewMemberModal.vue'
|
||||
import AddEvaluatorModal from '@/components/Modals/AddEvaluatorModal.vue'
|
||||
|
||||
const show = defineModel('show')
|
||||
const search = ref('')
|
||||
const showForm = ref(false)
|
||||
const email = ref('')
|
||||
const show = defineModel('show')
|
||||
const showExistingUser = ref(false)
|
||||
const showNewEvaluator = ref(false)
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
@@ -150,20 +160,8 @@ const evaluators = createListResource({
|
||||
orderBy: 'creation desc',
|
||||
})
|
||||
|
||||
const addEvaluator = (close: () => void) => {
|
||||
call('lms.lms.api.add_an_evaluator', {
|
||||
email: email.value,
|
||||
})
|
||||
.then(() => {
|
||||
email.value = ''
|
||||
evaluators.reload()
|
||||
toast.success(__('Evaluator added successfully'))
|
||||
close()
|
||||
})
|
||||
.catch((error: any) => {
|
||||
toast.error(__(error.messages[0] || error.messages))
|
||||
console.error('Error adding evaluator:', error)
|
||||
})
|
||||
const onMemberCreated = () => {
|
||||
evaluators.reload()
|
||||
}
|
||||
|
||||
watch(search, () => {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title:
|
||||
accountID === 'new'
|
||||
? __('New Google Meet Account')
|
||||
: __('Edit Google Meet Account'),
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
label: __('Save'),
|
||||
variant: 'solid',
|
||||
onClick: ({ close }) => {
|
||||
saveAccount(close)
|
||||
},
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="mb-4">
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="account.enabled"
|
||||
:label="__('Enabled')"
|
||||
:description="
|
||||
__('Activate this Google Meet account for scheduling meetings.')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<FormControl
|
||||
v-model="account.name"
|
||||
:label="__('Account Name')"
|
||||
type="text"
|
||||
:required="true"
|
||||
/>
|
||||
<Link
|
||||
v-model="account.member"
|
||||
:label="__('Member')"
|
||||
doctype="Course Evaluator"
|
||||
:onCreate="(value: string, close: () => void) => openSettings('Members', close)"
|
||||
:required="true"
|
||||
/>
|
||||
<Link
|
||||
v-model="account.google_calendar"
|
||||
:label="__('Google Calendar')"
|
||||
doctype="Google Calendar"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { call, Dialog, FormControl, Switch, toast } from 'frappe-ui'
|
||||
import { inject, reactive, watch } from 'vue'
|
||||
import { User } from '@/components/Settings/types'
|
||||
import { openSettings, cleanError } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { useTelemetry } from 'frappe-ui/frappe'
|
||||
|
||||
interface GoogleMeetAccount {
|
||||
name: string
|
||||
account_name: string
|
||||
enabled: boolean
|
||||
member: string
|
||||
google_calendar: string
|
||||
}
|
||||
|
||||
interface GoogleMeetAccounts {
|
||||
data: GoogleMeetAccount[]
|
||||
reload: () => void
|
||||
insert: {
|
||||
submit: (
|
||||
data: GoogleMeetAccount,
|
||||
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||
) => void
|
||||
}
|
||||
setValue: {
|
||||
submit: (
|
||||
data: GoogleMeetAccount,
|
||||
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||
) => void
|
||||
}
|
||||
}
|
||||
|
||||
const show = defineModel('show')
|
||||
const user = inject<User | null>('$user')
|
||||
const googleMeetAccounts = defineModel<GoogleMeetAccounts>('googleMeetAccounts')
|
||||
const { capture } = useTelemetry()
|
||||
|
||||
const account = reactive({
|
||||
name: '',
|
||||
enabled: false,
|
||||
member: user?.data?.name || '',
|
||||
google_calendar: '',
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
accountID: {
|
||||
type: String,
|
||||
default: 'new',
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.accountID,
|
||||
(val) => {
|
||||
if (val === 'new') {
|
||||
account.name = ''
|
||||
account.enabled = false
|
||||
account.member = user?.data?.name || ''
|
||||
account.google_calendar = ''
|
||||
} else if (val && val !== 'new') {
|
||||
const acc = googleMeetAccounts.value?.data.find((acc) => acc.name === val)
|
||||
if (acc) {
|
||||
account.name = acc.name
|
||||
account.enabled = acc.enabled || false
|
||||
account.member = acc.member
|
||||
account.google_calendar = acc.google_calendar
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const saveAccount = (close: () => void) => {
|
||||
if (props.accountID == 'new') {
|
||||
createAccount(close)
|
||||
} else {
|
||||
updateAccount(close)
|
||||
}
|
||||
}
|
||||
|
||||
const createAccount = (close: () => void) => {
|
||||
googleMeetAccounts.value?.insert.submit(
|
||||
{
|
||||
account_name: account.name,
|
||||
...account,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
capture('google_meet_account_linked')
|
||||
googleMeetAccounts.value?.reload()
|
||||
close()
|
||||
toast.success(__('Google Meet Account created successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
console.error(err)
|
||||
close()
|
||||
toast.error(
|
||||
cleanError(err.messages[0]) ||
|
||||
__('Error creating Google Meet Account')
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateAccount = async (close: () => void) => {
|
||||
if (props.accountID != account.name) {
|
||||
await renameDoc()
|
||||
}
|
||||
setValue(close)
|
||||
}
|
||||
|
||||
const renameDoc = async () => {
|
||||
await call('frappe.client.rename_doc', {
|
||||
doctype: 'LMS Google Meet Settings',
|
||||
old_name: props.accountID,
|
||||
new_name: account.name,
|
||||
})
|
||||
}
|
||||
|
||||
const setValue = (close: () => void) => {
|
||||
googleMeetAccounts.value?.setValue.submit(
|
||||
{
|
||||
...account,
|
||||
name: account.name,
|
||||
account_name: props.accountID,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
googleMeetAccounts.value?.reload()
|
||||
close()
|
||||
toast.success(__('Google Meet Account updated successfully'))
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error(err)
|
||||
close()
|
||||
toast.error(
|
||||
cleanError(err.messages[0]) ||
|
||||
__('Error updating Google Meet Account')
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<div class="flex flex-col min-h-0 text-base">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ label }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-x-5">
|
||||
<Button @click="openForm('new')">
|
||||
<template #prefix>
|
||||
<Plus class="h-3 w-3 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="googleMeetAccounts.data?.length" class="overflow-y-auto">
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="googleMeetAccounts.data"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
onRowClick: (row) => {
|
||||
openForm(row.name)
|
||||
},
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in columns">
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon
|
||||
v-if="item.icon"
|
||||
:name="item.icon"
|
||||
class="h-4 w-4 stroke-1.5"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in googleMeetAccounts.data">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<template #prefix>
|
||||
<div v-if="column.key == 'member_name'">
|
||||
<Avatar
|
||||
class="flex items-center"
|
||||
:image="row['member_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="column.key == 'enabled'">
|
||||
<Badge v-if="row[column.key]" theme="green">
|
||||
{{ __('Enabled') }}
|
||||
</Badge>
|
||||
<Badge v-else theme="gray">
|
||||
{{ __('Disabled') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div v-else class="leading-5 text-sm">
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="removeAccount(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
</div>
|
||||
<GoogleMeetAccountModal
|
||||
v-model="showForm"
|
||||
v-model:googleMeetAccounts="googleMeetAccounts"
|
||||
:accountID="currentAccount"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Badge,
|
||||
call,
|
||||
createListResource,
|
||||
FeatherIcon,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
ListSelectBanner,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { cleanError } from '@/utils'
|
||||
import { User } from '@/components/Settings/types'
|
||||
import GoogleMeetAccountModal from '@/components/Settings/GoogleMeetAccountModal.vue'
|
||||
|
||||
const user = inject<User | null>('$user')
|
||||
const showForm = ref(false)
|
||||
const currentAccount = ref<string | null>(null)
|
||||
|
||||
const props = defineProps({
|
||||
label: String,
|
||||
description: String,
|
||||
})
|
||||
|
||||
const googleMeetAccounts = createListResource({
|
||||
doctype: 'LMS Google Meet Settings',
|
||||
fields: [
|
||||
'name',
|
||||
'enabled',
|
||||
'member',
|
||||
'member_name',
|
||||
'member_image',
|
||||
'google_calendar',
|
||||
],
|
||||
cache: ['googleMeetAccounts'],
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchGoogleMeetAccounts()
|
||||
})
|
||||
|
||||
const fetchGoogleMeetAccounts = () => {
|
||||
if (!user?.data?.is_moderator && !user?.data?.is_evaluator) return
|
||||
|
||||
if (!user?.data?.is_moderator) {
|
||||
googleMeetAccounts.update({
|
||||
filters: {
|
||||
member: user.data.name,
|
||||
},
|
||||
})
|
||||
}
|
||||
googleMeetAccounts.reload()
|
||||
}
|
||||
|
||||
const openForm = (accountID: string) => {
|
||||
currentAccount.value = accountID
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
const removeAccount = (selections, unselectAll) => {
|
||||
call('lms.lms.api.delete_documents', {
|
||||
doctype: 'LMS Google Meet Settings',
|
||||
documents: Array.from(selections),
|
||||
})
|
||||
.then(() => {
|
||||
googleMeetAccounts.reload()
|
||||
toast.success(__('Google Meet Account deleted successfully'))
|
||||
unselectAll()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
cleanError(err.messages[0]) || __('Error deleting Google Meet Account')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const columns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Member'),
|
||||
key: 'member_name',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
label: __('Account Name'),
|
||||
key: 'name',
|
||||
icon: 'video',
|
||||
},
|
||||
{
|
||||
label: __('Status'),
|
||||
key: 'enabled',
|
||||
align: 'center',
|
||||
icon: 'check-square',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -2,15 +2,15 @@
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2">
|
||||
<Button variant="solid" @click="() => (showForm = !showForm)">
|
||||
<div class="flex item-center gap-x-2">
|
||||
<Button @click="showNewMember = true">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
@@ -31,7 +31,7 @@
|
||||
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
|
||||
</template>
|
||||
</FormControl>
|
||||
<div class="overflow-y-scroll h-[60vh]">
|
||||
<div class="overflow-y-auto max-h-[60vh]">
|
||||
<ul class="divide-y divide-outline-gray-modals">
|
||||
<li
|
||||
v-for="member in memberList"
|
||||
@@ -39,7 +39,7 @@
|
||||
>
|
||||
<div
|
||||
@click="openProfile(member.username)"
|
||||
class="flex items-center space-x-3 col-span-2"
|
||||
class="flex items-center gap-x-3 col-span-2"
|
||||
>
|
||||
<Avatar
|
||||
:image="member.user_image"
|
||||
@@ -58,7 +58,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center text-ink-gray-9 space-x-1 bg-surface-gray-2 px-2 py-1.5 rounded-md"
|
||||
class="flex items-center text-ink-gray-9 gap-x-1 bg-surface-gray-2 px-2 py-1.5 rounded-md"
|
||||
v-if="member.role && member.role !== 'LMS Student'"
|
||||
>
|
||||
<Shield class="size-4 stroke-1.5" />
|
||||
@@ -82,56 +82,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
v-model="showForm"
|
||||
:options="{
|
||||
title: __('Add a new member'),
|
||||
size: 'lg',
|
||||
actions: [{
|
||||
label: __('Add'),
|
||||
variant: 'solid',
|
||||
onClick({ close }: any) {
|
||||
addMember(close)
|
||||
}
|
||||
}]
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex items-center space-x-2">
|
||||
<FormControl
|
||||
v-model="member.email"
|
||||
:label="__('Email')"
|
||||
placeholder="jane@doe.com"
|
||||
type="email"
|
||||
class="w-full"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="member.first_name"
|
||||
:label="__('First Name')"
|
||||
placeholder="Jane"
|
||||
type="text"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
<NewMemberModal v-model="showNewMember" @created="onMemberCreated" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
call,
|
||||
createResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { Avatar, Button, createResource, FormControl } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, watch, reactive, inject } from 'vue'
|
||||
import { ref, watch, inject } from 'vue'
|
||||
import { RefreshCw, Plus, Search, Shield } from 'lucide-vue-next'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import type { User } from '@/components/Settings/types'
|
||||
import { useTelemetry } from 'frappe-ui/frappe'
|
||||
import NewMemberModal from '@/components/Modals/NewMemberModal.vue'
|
||||
|
||||
type Member = {
|
||||
username: string
|
||||
@@ -147,16 +107,11 @@ const search = ref('')
|
||||
const start = ref(0)
|
||||
const memberList = ref<Member[]>([])
|
||||
const hasNextPage = ref(false)
|
||||
const showForm = ref(false)
|
||||
const showNewMember = ref(false)
|
||||
const user = inject<User | null>('$user')
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
const { capture } = useTelemetry()
|
||||
|
||||
const member = reactive({
|
||||
email: '',
|
||||
first_name: '',
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
@@ -194,30 +149,12 @@ const openProfile = (username: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
const addMember = (close: () => void) => {
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'User',
|
||||
first_name: member.first_name,
|
||||
email: member.email,
|
||||
},
|
||||
})
|
||||
.then((data: Member) => {
|
||||
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
|
||||
capture('user_added')
|
||||
show.value = false
|
||||
router.push({
|
||||
name: 'ProfileRoles',
|
||||
params: {
|
||||
username: data.username,
|
||||
},
|
||||
})
|
||||
close()
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error(err)
|
||||
toast.error(__(err.messages?.[0] || err))
|
||||
})
|
||||
const onMemberCreated = (data: any) => {
|
||||
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
|
||||
capture('user_added')
|
||||
memberList.value = []
|
||||
start.value = 0
|
||||
members.reload()
|
||||
}
|
||||
|
||||
watch(search, () => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}"
|
||||
>
|
||||
<template #body-header>
|
||||
<div class="text-lg font-semibold">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{
|
||||
gatewayID === 'new'
|
||||
? __('New Payment Gateway')
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="pb-5 float-right">
|
||||
<div class="pb-5 float-end">
|
||||
<Button variant="solid" @click="saveSettings(close)">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
@@ -131,7 +131,7 @@ watch(newGateway, () => {
|
||||
let fields = gatewayFields.data || []
|
||||
arrangeFields(fields)
|
||||
newGatewayFields.value = makeSections(fields)
|
||||
prepareGatewayData()
|
||||
prepareGatewayData(fields)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -209,13 +209,11 @@ const allGatewayOptions = computed(() => {
|
||||
return options.map((gateway: string) => ({ label: gateway, value: gateway }))
|
||||
})
|
||||
|
||||
const prepareGatewayData = () => {
|
||||
const prepareGatewayData = (fields: any[]) => {
|
||||
newGatewayData.value = {}
|
||||
if (newGatewayFields.value.length) {
|
||||
newGatewayFields.value.forEach((field: any) => {
|
||||
newGatewayData.value[field.fieldname] = field.default || ''
|
||||
})
|
||||
}
|
||||
fields.forEach((field: any) => {
|
||||
newGatewayData.value[field.name] = field.default || ''
|
||||
})
|
||||
}
|
||||
|
||||
const makeSections = (fields: any[]) => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
@@ -17,7 +17,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="paymentGateways.data?.length" class="overflow-y-scroll">
|
||||
<div v-if="paymentGateways.data?.length" class="overflow-y-auto">
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="paymentGateways.data"
|
||||
@@ -30,7 +30,7 @@
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in columns">
|
||||
<template #prefix="{ item }">
|
||||
@@ -88,6 +88,7 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
FeatherIcon,
|
||||
ListView,
|
||||
@@ -97,10 +98,12 @@ import {
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
ListSelectBanner,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import PaymentGatewayDetails from '@/components/Settings/PaymentGatewayDetails.vue'
|
||||
import { cleanError } from '@/utils'
|
||||
|
||||
const showForm = ref(false)
|
||||
const currentGateway = ref(null)
|
||||
@@ -128,6 +131,23 @@ const openForm = (gatewayID) => {
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
const removeAccount = (selections, unselectAll) => {
|
||||
call('lms.lms.api.delete_documents', {
|
||||
doctype: 'Payment Gateway',
|
||||
documents: Array.from(selections),
|
||||
})
|
||||
.then(() => {
|
||||
paymentGateways.reload()
|
||||
toast.success(__('Payment gateways deleted successfully'))
|
||||
unselectAll()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
cleanError(err.messages[0]) || __('Error deleting payment gateways')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const columns = computed(() => {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -2,23 +2,25 @@
|
||||
<div class="flex flex-col h-full text-base overflow-y-hidden">
|
||||
<div class="">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class="text-xl font-semibold leading-none text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Badge
|
||||
v-if="data.isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
<Button variant="solid" :loading="data.save.loading" @click="update">
|
||||
{{ __('Update') }}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="solid" :loading="data.save.loading" @click="update">
|
||||
{{ __('Update') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
'flex justify-between space-x-8 w-full': section.columns.length > 1,
|
||||
'flex justify-between gap-x-8 w-full': section.columns.length > 1,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
@@ -20,6 +20,7 @@
|
||||
:doctype="field.doctype"
|
||||
:label="__(field.label)"
|
||||
:description="__(field.description)"
|
||||
:required="field.reqd"
|
||||
/>
|
||||
|
||||
<div v-else-if="field.type == 'Code'">
|
||||
@@ -63,7 +64,7 @@
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else>
|
||||
<div class="flex items-center text-sm space-x-2">
|
||||
<div class="flex items-center text-sm gap-x-2">
|
||||
<div
|
||||
class="flex items-center justify-center rounded border border-outline-gray-modals bg-surface-gray-2"
|
||||
:class="field.size == 'lg' ? 'px-5 py-5' : 'px-20 py-8'"
|
||||
@@ -90,7 +91,7 @@
|
||||
</div>
|
||||
<X
|
||||
@click="data[field.name] = null"
|
||||
class="border text-ink-gray-7 border-outline-gray-modals rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
class="border text-ink-gray-7 border-outline-gray-modals rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ms-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,6 +116,7 @@
|
||||
:rows="field.rows"
|
||||
:options="field.options"
|
||||
:description="field.description"
|
||||
:required="field.reqd"
|
||||
placeholder=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<div
|
||||
v-if="activeTab && data.doc"
|
||||
:key="activeTab.label"
|
||||
class="flex flex-1 flex-col p-8 bg-surface-modal overflow-x-auto"
|
||||
class="flex flex-1 flex-col p-8 bg-surface-modal overflow-x-auto overflow-y-auto"
|
||||
>
|
||||
<component
|
||||
v-if="activeTab.template"
|
||||
@@ -42,8 +42,8 @@
|
||||
...(activeTab.label == 'Branding'
|
||||
? { sections: activeTab.sections }
|
||||
: {}),
|
||||
...(activeTab.label == 'Evaluators' ||
|
||||
activeTab.label == 'Members' ||
|
||||
...(activeTab.label == 'Members' ||
|
||||
activeTab.label == 'Evaluators' ||
|
||||
activeTab.label == 'Transactions'
|
||||
? { 'onUpdate:show': (val) => (show = val), show }
|
||||
: {}),
|
||||
@@ -76,6 +76,7 @@ import PaymentGateways from '@/components/Settings/PaymentGateways.vue'
|
||||
import Coupons from '@/components/Settings/Coupons/Coupons.vue'
|
||||
import Transactions from '@/components/Settings/Transactions/Transactions.vue'
|
||||
import ZoomSettings from '@/components/Settings/ZoomSettings.vue'
|
||||
import GoogleMeetSettings from '@/components/Settings/GoogleMeetSettings.vue'
|
||||
import Badges from '@/components/Settings/Badges.vue'
|
||||
|
||||
const show = defineModel()
|
||||
@@ -219,6 +220,25 @@ const tabsStructure = computed(() => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Jobs',
|
||||
columns: [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Allow Job Posting',
|
||||
name: 'allow_job_posting',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'If enabled, users can post job openings on the job board. Else only admins can post jobs.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fields: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '',
|
||||
columns: [
|
||||
@@ -249,34 +269,6 @@ const tabsStructure = computed(() => {
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Lists',
|
||||
hideLabel: false,
|
||||
items: [
|
||||
{
|
||||
label: 'Members',
|
||||
description:
|
||||
'Add new members or manage roles and permissions of existing members',
|
||||
icon: 'UserRoundPlus',
|
||||
template: markRaw(Members),
|
||||
},
|
||||
{
|
||||
label: 'Evaluators',
|
||||
description: '',
|
||||
icon: 'UserCheck',
|
||||
description:
|
||||
'Add new evaluators or check the slots existing evaluators',
|
||||
template: markRaw(Evaluators),
|
||||
},
|
||||
{
|
||||
label: 'Zoom Accounts',
|
||||
description:
|
||||
'Manage zoom accounts to conduct live classes from batches',
|
||||
icon: 'Video',
|
||||
template: markRaw(ZoomSettings),
|
||||
},
|
||||
{
|
||||
label: 'Badges',
|
||||
description:
|
||||
@@ -298,6 +290,27 @@ const tabsStructure = computed(() => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Users',
|
||||
hideLabel: false,
|
||||
items: [
|
||||
{
|
||||
label: 'Members',
|
||||
description:
|
||||
'Add new members or manage roles and permissions of existing members',
|
||||
icon: 'User',
|
||||
template: markRaw(Members),
|
||||
},
|
||||
{
|
||||
label: 'Evaluators',
|
||||
description: '',
|
||||
icon: 'UserCircle2',
|
||||
description:
|
||||
'Add new evaluators or check the slots of existing evaluators',
|
||||
template: markRaw(Evaluators),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Payment',
|
||||
hideLabel: false,
|
||||
@@ -318,29 +331,62 @@ const tabsStructure = computed(() => {
|
||||
doctype: 'Currency',
|
||||
},
|
||||
{
|
||||
label: 'Payment Gateway',
|
||||
name: 'payment_gateway',
|
||||
type: 'Link',
|
||||
doctype: 'Payment Gateway',
|
||||
label: 'Show USD equivalent amount',
|
||||
name: 'show_usd_equivalent',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'If enabled, it shows the USD equivalent amount for all transactions based on the current exchange rate.',
|
||||
},
|
||||
{
|
||||
label: 'Apply rounding on equivalent',
|
||||
name: 'apply_rounding',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'If enabled, it applies rounding on the USD equivalent amount.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Payment Gateway',
|
||||
name: 'payment_gateway',
|
||||
type: 'Link',
|
||||
doctype: 'Payment Gateway',
|
||||
},
|
||||
{
|
||||
label: 'Apply GST for India',
|
||||
name: 'apply_gst',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'If enabled, GST will be applied to the price for students from India.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Payment Reminders',
|
||||
columns: [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Show USD equivalent amount',
|
||||
name: 'show_usd_equivalent',
|
||||
label: 'Send payment reminders for batch',
|
||||
name: 'send_payment_reminders_for_batch',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'If enabled, it sends payment reminders to students who left the payment incomplete for a batch.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Apply rounding on equivalent',
|
||||
name: 'apply_rounding',
|
||||
label: 'Send payment reminders for course',
|
||||
name: 'send_payment_reminders_for_course',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'If enabled, it sends payment reminders to students who left the payment incomplete for a course.',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -368,6 +414,26 @@ const tabsStructure = computed(() => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Conferencing',
|
||||
hideLabel: false,
|
||||
items: [
|
||||
{
|
||||
label: 'Zoom',
|
||||
description:
|
||||
'Manage zoom accounts to conduct live classes from batches',
|
||||
icon: 'Video',
|
||||
template: markRaw(ZoomSettings),
|
||||
},
|
||||
{
|
||||
label: 'Google Meet',
|
||||
description:
|
||||
'Manage Google Meet accounts to conduct live classes from batches',
|
||||
icon: 'Presentation',
|
||||
template: markRaw(GoogleMeetSettings),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Customize',
|
||||
hideLabel: false,
|
||||
@@ -375,6 +441,8 @@ const tabsStructure = computed(() => {
|
||||
{
|
||||
label: 'Branding',
|
||||
icon: 'Blocks',
|
||||
description:
|
||||
'Customize the brand name and logo to make the application your own',
|
||||
template: markRaw(BrandSettings),
|
||||
sections: [
|
||||
{
|
||||
@@ -463,6 +531,8 @@ const tabsStructure = computed(() => {
|
||||
{
|
||||
label: 'Signup',
|
||||
icon: 'LogIn',
|
||||
description:
|
||||
'Manage the settings related to user signup and registration',
|
||||
sections: [
|
||||
{
|
||||
columns: [
|
||||
@@ -498,6 +568,8 @@ const tabsStructure = computed(() => {
|
||||
{
|
||||
label: 'SEO',
|
||||
icon: 'Search',
|
||||
description:
|
||||
'Manage the SEO settings to improve your website ranking on search engines',
|
||||
sections: [
|
||||
{
|
||||
columns: [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full text-base">
|
||||
<div class="flex items-center justify-between mb-10 -ml-1.5">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center justify-between mb-10 -ms-1.5">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<ChevronLeft
|
||||
class="size-5 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||
@click="emit('updateStep', 'list')"
|
||||
@@ -10,7 +10,7 @@
|
||||
{{ __('Transaction Details') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Button
|
||||
v-if="
|
||||
transactionData?.payment_for_document_type &&
|
||||
@@ -55,17 +55,18 @@
|
||||
:label="__('Member')"
|
||||
doctype="User"
|
||||
v-model="transactionData.member"
|
||||
:required="true"
|
||||
:required="!!fieldMeta.member?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Billing Name')"
|
||||
v-model="transactionData.billing_name"
|
||||
:required="true"
|
||||
:required="!!fieldMeta.billing_name?.reqd"
|
||||
/>
|
||||
<Link
|
||||
:label="__('Source')"
|
||||
v-model="transactionData.source"
|
||||
doctype="LMS Source"
|
||||
:required="!!fieldMeta.source?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
type="select"
|
||||
@@ -73,16 +74,18 @@
|
||||
:label="__('Payment For Document Type')"
|
||||
v-model="transactionData.payment_for_document_type"
|
||||
doctype="DocType"
|
||||
:required="!!fieldMeta.payment_for_document_type?.reqd"
|
||||
/>
|
||||
<Link
|
||||
v-if="transactionData.payment_for_document_type"
|
||||
:label="__('Payment For Document')"
|
||||
v-model="transactionData.payment_for_document"
|
||||
:doctype="transactionData.payment_for_document_type"
|
||||
:required="!!fieldMeta.payment_for_document?.reqd"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold mt-10">
|
||||
<div class="font-semibold mt-10 text-ink-gray-9">
|
||||
{{ __('Payment Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
@@ -90,22 +93,23 @@
|
||||
:label="__('Currency')"
|
||||
v-model="transactionData.currency"
|
||||
doctype="Currency"
|
||||
:required="true"
|
||||
:required="!!fieldMeta.currency?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Amount')"
|
||||
v-model="transactionData.amount"
|
||||
:required="true"
|
||||
:required="!!fieldMeta.amount?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="transactionData.amount_with_gst"
|
||||
:label="__('Amount with GST')"
|
||||
v-model="transactionData.amount_with_gst"
|
||||
:required="!!fieldMeta.amount_with_gst?.reqd"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="transactionData.coupon">
|
||||
<div class="font-semibold mt-10">
|
||||
<div class="font-semibold mt-10 text-ink-gray-9">
|
||||
{{ __('Coupon Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
@@ -113,26 +117,30 @@
|
||||
v-if="transactionData.coupon"
|
||||
:label="__('Coupon Code')"
|
||||
v-model="transactionData.coupon"
|
||||
:required="!!fieldMeta.coupon?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="transactionData.coupon"
|
||||
:label="__('Coupon Code')"
|
||||
v-model="transactionData.coupon_code"
|
||||
:required="!!fieldMeta.coupon_code?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="transactionData.coupon"
|
||||
:label="__('Discount Amount')"
|
||||
v-model="transactionData.discount_amount"
|
||||
:required="!!fieldMeta.discount_amount?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="transactionData.coupon"
|
||||
:label="__('Original Amount')"
|
||||
v-model="transactionData.original_amount"
|
||||
:required="!!fieldMeta.original_amount?.reqd"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold mt-10">
|
||||
<div class="font-semibold mt-10 text-ink-gray-9">
|
||||
{{ __('Billing Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
@@ -140,24 +148,34 @@
|
||||
:label="__('Address')"
|
||||
v-model="transactionData.address"
|
||||
doctype="Address"
|
||||
:required="true"
|
||||
:required="!!fieldMeta.address?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('GSTIN')"
|
||||
v-model="transactionData.gstin"
|
||||
:required="!!fieldMeta.gstin?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('PAN')"
|
||||
v-model="transactionData.pan"
|
||||
:required="!!fieldMeta.pan?.reqd"
|
||||
/>
|
||||
<FormControl :label="__('GSTIN')" v-model="transactionData.gstin" />
|
||||
<FormControl :label="__('PAN')" v-model="transactionData.pan" />
|
||||
<FormControl
|
||||
:label="__('Payment ID')"
|
||||
v-model="transactionData.payment_id"
|
||||
:required="!!fieldMeta.payment_id?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Order ID')"
|
||||
v-model="transactionData.order_id"
|
||||
:required="!!fieldMeta.order_id?.reqd"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, FormControl, toast } from 'frappe-ui'
|
||||
import { Button, FormControl, Switch, toast } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ChevronLeft } from 'lucide-vue-next'
|
||||
@@ -171,6 +189,10 @@ const show = defineModel('show')
|
||||
const props = defineProps<{
|
||||
transactions: any
|
||||
data: any
|
||||
fieldMeta: Record<
|
||||
string,
|
||||
{ reqd?: number; default?: string; description?: string }
|
||||
>
|
||||
}>()
|
||||
|
||||
const saveTransaction = () => {
|
||||
@@ -211,48 +233,49 @@ const updateTransaction = () => {
|
||||
}
|
||||
|
||||
const openDetails = () => {
|
||||
if (props.data) {
|
||||
const docType = props.data.payment_for_document_type
|
||||
const docName = props.data.payment_for_document
|
||||
if (docType && docName) {
|
||||
router.push({
|
||||
name: docType == 'LMS Course' ? 'CourseDetail' : 'BatchDetail',
|
||||
params: {
|
||||
[docType == 'LMS Course' ? 'courseName' : 'batchName']: docName,
|
||||
},
|
||||
})
|
||||
}
|
||||
const docType = transactionData.value?.payment_for_document_type
|
||||
const docName = transactionData.value?.payment_for_document
|
||||
if (docType && docName) {
|
||||
router.push({
|
||||
name: docType == 'LMS Course' ? 'CourseDetail' : 'BatchDetail',
|
||||
params: {
|
||||
[docType == 'LMS Course' ? 'courseName' : 'batchName']: docName,
|
||||
},
|
||||
})
|
||||
show.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const emptyTransactionData = {
|
||||
const getDefault = (fieldname: string) =>
|
||||
props.fieldMeta[fieldname]?.default || null
|
||||
|
||||
const getEmptyTransactionData = () => ({
|
||||
payment_received: false,
|
||||
payment_for_certificate: false,
|
||||
member: null,
|
||||
billing_name: null,
|
||||
source: null,
|
||||
payment_for_document_type: null,
|
||||
payment_for_document: null,
|
||||
member: getDefault('member'),
|
||||
billing_name: getDefault('billing_name'),
|
||||
source: getDefault('source'),
|
||||
payment_for_document_type: getDefault('payment_for_document_type'),
|
||||
payment_for_document: getDefault('payment_for_document'),
|
||||
member_consent: false,
|
||||
currency: null,
|
||||
amount: null,
|
||||
amount_with_gst: null,
|
||||
coupon: null,
|
||||
coupon_code: null,
|
||||
discount_amount: null,
|
||||
original_amount: null,
|
||||
order_id: null,
|
||||
payment_id: null,
|
||||
gstin: null,
|
||||
pan: null,
|
||||
address: null,
|
||||
}
|
||||
currency: getDefault('currency'),
|
||||
amount: getDefault('amount'),
|
||||
amount_with_gst: getDefault('amount_with_gst'),
|
||||
coupon: getDefault('coupon'),
|
||||
coupon_code: getDefault('coupon_code'),
|
||||
discount_amount: getDefault('discount_amount'),
|
||||
original_amount: getDefault('original_amount'),
|
||||
order_id: getDefault('order_id'),
|
||||
payment_id: getDefault('payment_id'),
|
||||
gstin: getDefault('gstin'),
|
||||
pan: getDefault('pan'),
|
||||
address: getDefault('address'),
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(newVal) => {
|
||||
transactionData.value = newVal ? { ...newVal } : emptyTransactionData
|
||||
transactionData.value = newVal ? { ...newVal } : getEmptyTransactionData()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
@@ -17,7 +17,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-5 mb-4">
|
||||
<div class="flex items-center gap-x-5 mb-4">
|
||||
<FormControl
|
||||
v-model="billingName"
|
||||
:placeholder="__('Filter by Billing Name')"
|
||||
@@ -39,21 +39,21 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="transactions.data?.length" class="overflow-y-scroll">
|
||||
<div v-if="transactions.data?.length" class="overflow-y-auto">
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="transactions.data"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
selectable: false,
|
||||
onRowClick: (row: { [key: string]: any }) => {
|
||||
openForm(row)
|
||||
},
|
||||
}"
|
||||
showTooltip: false,
|
||||
selectable: false,
|
||||
onRowClick: (row: { [key: string]: any }) => {
|
||||
openForm(row)
|
||||
},
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in columns">
|
||||
<template #prefix="{ item }">
|
||||
@@ -116,6 +116,7 @@ import {
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
FormControl,
|
||||
Switch,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { RefreshCw } from 'lucide-vue-next'
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
v-if="step == 'new'"
|
||||
:transactions="transactions"
|
||||
:data="data"
|
||||
:fieldMeta="fieldMeta.data || {}"
|
||||
v-model:show="show"
|
||||
@updateStep="updateStep"
|
||||
/>
|
||||
@@ -17,13 +18,14 @@
|
||||
v-else-if="step == 'details'"
|
||||
:transactions="transactions"
|
||||
:data="data"
|
||||
:fieldMeta="fieldMeta.data || {}"
|
||||
v-model:show="show"
|
||||
@updateStep="updateStep"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { createListResource } from 'frappe-ui'
|
||||
import { createListResource, createResource } from 'frappe-ui'
|
||||
import TransactionList from '@/components/Settings/Transactions/TransactionList.vue'
|
||||
import TransactionDetails from '@/components/Settings/Transactions/TransactionDetails.vue'
|
||||
|
||||
@@ -45,6 +47,11 @@ const updateStep = (newStep: 'list' | 'new' | 'edit', newData: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const fieldMeta = createResource({
|
||||
url: 'lms.lms.api.get_payment_field_meta',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const transactions = createListResource({
|
||||
doctype: 'LMS Payment',
|
||||
fields: [
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
{{ label }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
{{ __(description || '') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-5">
|
||||
<div class="flex items-center gap-x-5">
|
||||
<Button @click="openForm('new')">
|
||||
<template #prefix>
|
||||
<Plus class="h-3 w-3 stroke-1.5" />
|
||||
@@ -18,7 +18,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="zoomAccounts.data?.length" class="overflow-y-scroll">
|
||||
<div v-if="zoomAccounts.data?.length" class="overflow-y-auto">
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="zoomAccounts.data"
|
||||
@@ -31,7 +31,7 @@
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center gap-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in columns">
|
||||
<template #prefix="{ item }">
|
||||
@@ -90,6 +90,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<ZoomAccountModal
|
||||
v-if="showForm"
|
||||
v-model="showForm"
|
||||
v-model:zoomAccounts="zoomAccounts"
|
||||
:accountID="currentAccount"
|
||||
@@ -100,7 +101,6 @@ import {
|
||||
Avatar,
|
||||
Button,
|
||||
Badge,
|
||||
call,
|
||||
createListResource,
|
||||
FeatherIcon,
|
||||
ListView,
|
||||
@@ -112,20 +112,18 @@ import {
|
||||
ListSelectBanner,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { cleanError } from '@/utils'
|
||||
import { User } from '@/components/Settings/types'
|
||||
import ZoomAccountModal from '@/components/Modals/ZoomAccountModal.vue'
|
||||
|
||||
const user = inject<User | null>('$user')
|
||||
const showForm = ref(false)
|
||||
const currentAccount = ref<string | null>(null)
|
||||
|
||||
const props = defineProps({
|
||||
label: String,
|
||||
description: String,
|
||||
})
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
description?: string
|
||||
}>()
|
||||
|
||||
const zoomAccounts = createListResource({
|
||||
doctype: 'LMS Zoom Settings',
|
||||
@@ -147,15 +145,6 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
const fetchZoomAccounts = () => {
|
||||
if (!user?.data?.is_moderator && !user?.data?.is_evaluator) return
|
||||
|
||||
if (!user?.data?.is_moderator) {
|
||||
zoomAccounts.update({
|
||||
filters: {
|
||||
member: user.data.name,
|
||||
},
|
||||
})
|
||||
}
|
||||
zoomAccounts.reload()
|
||||
}
|
||||
|
||||
@@ -164,21 +153,20 @@ const openForm = (accountID: string) => {
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
const removeAccount = (selections, unselectAll) => {
|
||||
call('lms.lms.api.delete_documents', {
|
||||
doctype: 'LMS Zoom Settings',
|
||||
documents: Array.from(selections),
|
||||
const removeAccount = (selections: Set<string>, unselectAll: () => void) => {
|
||||
Array.from(selections).forEach((accountID) => {
|
||||
zoomAccounts.delete.submit(accountID, {
|
||||
onSuccess() {
|
||||
toast.success(__('Zoom account deleted successfully'))
|
||||
fetchZoomAccounts()
|
||||
unselectAll()
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.error(cleanError(err.messages[0] || err))
|
||||
console.error(err)
|
||||
},
|
||||
})
|
||||
})
|
||||
.then(() => {
|
||||
zoomAccounts.reload()
|
||||
toast.success(__('Email Templates deleted successfully'))
|
||||
unselectAll()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
cleanError(err.messages[0]) || __('Error deleting email templates')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const columns = computed(() => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out border-r bg-surface-menu-bar"
|
||||
class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out border-e bg-surface-menu-bar overflow-x-hidden"
|
||||
:class="sidebarStore.isSidebarCollapsed ? 'w-14' : 'w-56'"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col overflow-hidden"
|
||||
class="flex flex-col overflow-y-auto"
|
||||
:class="sidebarStore.isSidebarCollapsed ? 'items-center' : ''"
|
||||
>
|
||||
<UserDropdown :isCollapsed="sidebarStore.isSidebarCollapsed" />
|
||||
@@ -31,21 +31,24 @@
|
||||
class="mt-4"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between pr-2 cursor-pointer"
|
||||
:class="sidebarStore.isSidebarCollapsed ? 'pl-3' : 'pl-4'"
|
||||
class="flex items-center justify-between pe-2 cursor-pointer"
|
||||
:class="sidebarStore.isSidebarCollapsed ? 'ps-3' : 'ps-4'"
|
||||
@click="toggleWebPages"
|
||||
>
|
||||
<div
|
||||
v-if="!sidebarStore.isSidebarCollapsed"
|
||||
class="flex items-center text-sm text-ink-gray-5 my-1"
|
||||
class="flex items-center text-ink-gray-5 my-1"
|
||||
>
|
||||
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
||||
<ChevronRight
|
||||
class="h-4 w-4 stroke-1.5 text-ink-gray-9 transition-all duration-300 ease-in-out"
|
||||
:class="{ 'rotate-90': !sidebarStore.isWebpagesCollapsed }"
|
||||
:class="{
|
||||
'rotate-90': sidebarStore.isWebpagesCollapsed,
|
||||
'rtl:rotate-180': !sidebarStore.isWebpagesCollapsed,
|
||||
}"
|
||||
/>
|
||||
</span>
|
||||
<span class="ml-2">
|
||||
<span class="ms-2">
|
||||
{{ __('More') }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -90,6 +93,56 @@
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
isStudent && !profileIsComplete && !sidebarStore.isSidebarCollapsed
|
||||
"
|
||||
class="flex flex-col gap-3 text-ink-gray-9 py-2.5 px-3 bg-surface-white shadow-sm rounded-md"
|
||||
>
|
||||
<div class="flex flex-col text-p-sm gap-1">
|
||||
<div class="inline-flex gap-1">
|
||||
<User class="h-4 my-0.5 shrink-0" />
|
||||
<div class="font-medium">
|
||||
{{ __('Complete your profile') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-ink-gray-7 leading-5">
|
||||
{{ __('Highlight what makes you unique and show your skills.') }}
|
||||
</div>
|
||||
</div>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: {
|
||||
username: userResource.data?.username,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button :label="__('My Profile')" class="w-full">
|
||||
<template #prefix>
|
||||
<ChevronsRight class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
<Tooltip
|
||||
v-if="
|
||||
isStudent && !profileIsComplete && sidebarStore.isSidebarCollapsed
|
||||
"
|
||||
:text="__('Complete your profile')"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: {
|
||||
username: userResource.data?.username,
|
||||
},
|
||||
}"
|
||||
class="flex items-center justify-center"
|
||||
>
|
||||
<User class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer" />
|
||||
</router-link>
|
||||
</Tooltip>
|
||||
<TrialBanner
|
||||
v-if="
|
||||
userResource.data?.is_system_manager && userResource.data?.is_fc_site
|
||||
@@ -109,12 +162,8 @@
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex items-center flex-1"
|
||||
:class="
|
||||
sidebarStore.isSidebarCollapsed
|
||||
? 'flex-col space-y-3'
|
||||
: 'flex-row space-x-3'
|
||||
"
|
||||
class="flex items-center flex-1 gap-3"
|
||||
:class="sidebarStore.isSidebarCollapsed ? 'flex-col' : 'flex-row'"
|
||||
>
|
||||
<Tooltip v-if="readOnlyMode && sidebarStore.isSidebarCollapsed">
|
||||
<CircleAlert
|
||||
@@ -132,10 +181,13 @@
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Powered by Learning')">
|
||||
<Zap
|
||||
<Tooltip
|
||||
v-if="showAppointmentIcon"
|
||||
:text="__('Book a free onboarding session with the Frappe team')"
|
||||
>
|
||||
<Phone
|
||||
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||
@click="redirectToWebsite()"
|
||||
@click="redirectToAppointmentScreen()"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip v-if="showOnboarding" :text="__('Help')">
|
||||
@@ -149,6 +201,12 @@
|
||||
"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Powered by Frappe Learning')">
|
||||
<Zap
|
||||
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||
@click="redirectToWebsite()"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip
|
||||
:text="
|
||||
@@ -157,8 +215,11 @@
|
||||
>
|
||||
<CollapseSidebar
|
||||
class="size-4 text-ink-gray-7 duration-300 stroke-1.5 ease-in-out cursor-pointer"
|
||||
:class="{
|
||||
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed,
|
||||
:style="{
|
||||
transform:
|
||||
isRtl !== sidebarStore.isSidebarCollapsed
|
||||
? 'rotateY(180deg)'
|
||||
: '',
|
||||
}"
|
||||
@click="toggleSidebar()"
|
||||
/>
|
||||
@@ -166,6 +227,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<HelpModal
|
||||
data-testid="onboarding-help-modal"
|
||||
v-if="showOnboarding && showHelpModal"
|
||||
v-model="showHelpModal"
|
||||
v-model:articles="articles"
|
||||
@@ -210,15 +272,19 @@ import {
|
||||
markRaw,
|
||||
h,
|
||||
onUnmounted,
|
||||
computed,
|
||||
} from 'vue'
|
||||
import {
|
||||
BookOpen,
|
||||
CircleAlert,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
ChevronsRight,
|
||||
CircleHelp,
|
||||
FolderTree,
|
||||
FileText,
|
||||
Phone,
|
||||
Plus,
|
||||
User,
|
||||
UserPlus,
|
||||
Users,
|
||||
BookText,
|
||||
@@ -260,6 +326,7 @@ const router = useRouter()
|
||||
let onboardingDetails
|
||||
let isOnboardingStepsCompleted = false
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const isRtl = document.documentElement.dir === 'rtl'
|
||||
const iconProps = {
|
||||
strokeWidth: 1.5,
|
||||
width: 16,
|
||||
@@ -607,12 +674,55 @@ watch(settingsStore.settings, () => {
|
||||
const updateSidebarLinks = () => {
|
||||
sidebarLinks.value = getSidebarLinks()
|
||||
updateSidebarLinksVisibility()
|
||||
updateUnreadCount()
|
||||
}
|
||||
|
||||
const redirectToWebsite = () => {
|
||||
window.open('https://frappe.io/learning', '_blank')
|
||||
}
|
||||
|
||||
const isStudent = computed(() => {
|
||||
return userResource.data?.is_student
|
||||
})
|
||||
|
||||
const profileIsComplete = computed(() => {
|
||||
return (
|
||||
userResource.data?.user_image &&
|
||||
userResource.data?.headline &&
|
||||
userResource.data?.bio
|
||||
)
|
||||
})
|
||||
|
||||
const showAppointmentIcon = computed(() => {
|
||||
let isTrialPlan = userResource.data?.site_info?.plan?.is_trial_plan
|
||||
let trialEndDate = calculateTrialEndDays(
|
||||
userResource.data?.site_info?.trial_end_date
|
||||
)
|
||||
return (
|
||||
userResource.data?.is_system_manager &&
|
||||
userResource.data?.is_fc_site &&
|
||||
isTrialPlan &&
|
||||
trialEndDate > 0
|
||||
)
|
||||
})
|
||||
|
||||
const calculateTrialEndDays = (trialEndDate) => {
|
||||
if (!trialEndDate) return 0
|
||||
|
||||
trialEndDate = new Date(trialEndDate)
|
||||
const today = new Date()
|
||||
const diffTime = trialEndDate - today
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
return diffDays
|
||||
}
|
||||
|
||||
const redirectToAppointmentScreen = () => {
|
||||
window.open(
|
||||
'https://calendar.google.com/calendar/u/0/appointments/schedules/AcZssZ0c7Z3XIpW1WgbeIuktSaoX6qudoYuSdRbIlJty5TW7p4IZaOk5viHQGwTNi6HpNVqzOZOTHcle',
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
socket.off('publish_lms_notifications')
|
||||
})
|
||||
|
||||
@@ -48,7 +48,7 @@ const apps = createResource({
|
||||
name: 'frappe',
|
||||
logo: '/assets/lms/images/desk.png',
|
||||
title: __('Desk'),
|
||||
route: '/desk/lms',
|
||||
route: '/desk/learning',
|
||||
},
|
||||
]
|
||||
data.map((app) => {
|
||||
|
||||
@@ -25,18 +25,18 @@
|
||||
class="flex-shrink-0 text-sm duration-300 ease-in-out"
|
||||
:class="
|
||||
isCollapsed
|
||||
? 'ml-0 w-0 overflow-hidden opacity-0'
|
||||
: 'ml-2 w-auto opacity-100'
|
||||
? 'ms-0 w-0 overflow-hidden opacity-0'
|
||||
: 'ms-2 w-auto opacity-100'
|
||||
"
|
||||
>
|
||||
{{ __(link.label) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="link.count && !isCollapsed"
|
||||
class="!ml-auto block text-xs text-ink-gray-5"
|
||||
class="!ms-auto block text-xs text-ink-gray-5"
|
||||
:class="
|
||||
isCollapsed && link.count > 9
|
||||
? 'absolute top-[2px] right-0 bg-surface-white'
|
||||
? 'absolute top-[2px] end-0 bg-surface-white'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
@@ -44,7 +44,7 @@
|
||||
</span>
|
||||
<div
|
||||
v-if="showControls && !isCollapsed"
|
||||
class="flex items-center space-x-2 !ml-auto block text-xs text-ink-gray-5 group-hover:visible invisible"
|
||||
class="flex items-center gap-x-2 !ms-auto block text-xs text-ink-gray-5 group-hover:visible invisible"
|
||||
>
|
||||
<component
|
||||
:is="icons['Edit']"
|
||||
|
||||
@@ -19,11 +19,11 @@
|
||||
/>
|
||||
<LMSLogo v-else class="w-8 h-8 rounded flex-shrink-0" />
|
||||
<div
|
||||
class="flex flex-1 flex-col text-left duration-300 ease-in-out"
|
||||
class="flex flex-1 flex-col text-start duration-300 ease-in-out"
|
||||
:class="
|
||||
isCollapsed
|
||||
? 'opacity-0 ml-0 w-0 overflow-hidden'
|
||||
: 'opacity-100 ml-2 w-auto'
|
||||
? 'opacity-0 ms-0 w-0 overflow-hidden'
|
||||
: 'opacity-100 ms-2 w-auto'
|
||||
"
|
||||
>
|
||||
<div class="text-base font-medium text-ink-gray-9 leading-none">
|
||||
@@ -47,8 +47,8 @@
|
||||
class="duration-300 ease-in-out"
|
||||
:class="
|
||||
isCollapsed
|
||||
? 'opacity-0 ml-0 w-0 overflow-hidden'
|
||||
: 'opacity-100 ml-2 w-auto'
|
||||
? 'opacity-0 ms-0 w-0 overflow-hidden'
|
||||
: 'opacity-100 ms-2 w-auto'
|
||||
"
|
||||
>
|
||||
<ChevronDown class="h-4 w-4 text-ink-gray-7" />
|
||||
@@ -65,9 +65,10 @@
|
||||
|
||||
<script setup>
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { Dropdown } from 'frappe-ui'
|
||||
import { call, Dropdown, toast } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { convertToTitleCase } from '@/utils'
|
||||
import { applyTheme, toggleTheme, theme } from '@/utils/theme'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import { markRaw, watch, ref, onMounted, computed } from 'vue'
|
||||
@@ -85,7 +86,7 @@ import {
|
||||
User,
|
||||
Settings,
|
||||
Sun,
|
||||
Zap,
|
||||
Trash2,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -94,7 +95,6 @@ let { userResource } = usersStore()
|
||||
const settingsStore = useSettings()
|
||||
let { isLoggedIn } = sessionStore()
|
||||
const showSettingsModal = ref(false)
|
||||
const theme = ref('light')
|
||||
const frappeCloudBaseEndpoint = 'https://frappecloud.com'
|
||||
const $dialog = createDialog
|
||||
|
||||
@@ -106,9 +106,8 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
theme.value = localStorage.getItem('theme') || 'light'
|
||||
if (['light', 'dark'].includes(theme.value)) {
|
||||
document.documentElement.setAttribute('data-theme', theme.value)
|
||||
applyTheme(theme.value)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -119,13 +118,6 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const toggleTheme = () => {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme')
|
||||
theme.value = currentTheme === 'dark' ? 'light' : 'dark'
|
||||
document.documentElement.setAttribute('data-theme', theme.value)
|
||||
localStorage.setItem('theme', theme.value)
|
||||
}
|
||||
|
||||
const userDropdownOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
@@ -175,6 +167,19 @@ const userDropdownOptions = computed(() => {
|
||||
return userResource.data?.is_moderator
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Clear Demo Data',
|
||||
icon: Trash2,
|
||||
onClick: () => {
|
||||
clearDemoDataConfirmation()
|
||||
},
|
||||
condition: () => {
|
||||
return (
|
||||
userResource.data?.is_moderator &&
|
||||
settingsStore.settings.data?.demo_data_present
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: FrappeCloudIcon,
|
||||
label: 'Login to Frappe Cloud',
|
||||
@@ -234,4 +239,36 @@ const loginToFrappeCloud = () => {
|
||||
let redirect_to = '/dashboard/sites/' + userResource.data.sitename
|
||||
window.open(`${frappeCloudBaseEndpoint}${redirect_to}`, '_blank')
|
||||
}
|
||||
|
||||
const clearDemoDataConfirmation = () => {
|
||||
$dialog({
|
||||
title: __('Confirm clearing demo data?'),
|
||||
message: __(
|
||||
'Are you sure you want to clear the demo data? This would delete the course "A guide to Frappe Learning" along with all its associated data. This action cannot be undone.'
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
label: __('Confirm'),
|
||||
theme: 'red',
|
||||
variant: 'solid',
|
||||
onClick(close) {
|
||||
clearDemoData()
|
||||
close()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const clearDemoData = () => {
|
||||
call('lms.lms.api.clear_demo_data')
|
||||
.then(() => {
|
||||
window.location.href = '/lms'
|
||||
toast.success(__('Demo data cleared successfully'))
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(__(error.message || 'Error clearing demo data'))
|
||||
console.error('Error clearing demo data:', error)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
{{ tags }}
|
||||
<div
|
||||
v-for="tag in tags?.split(', ')"
|
||||
class="flex items-center bg-surface-gray-2 p-2 rounded-md mr-2"
|
||||
class="flex items-center bg-surface-gray-2 p-2 rounded-md me-2"
|
||||
>
|
||||
{{ tag }}
|
||||
<X
|
||||
class="stroke-1.5 w-3 h-3 ml-2 cursor-pointer"
|
||||
class="stroke-1.5 w-3 h-3 ms-2 cursor-pointer"
|
||||
@click="removeTag(tag)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user