mirror of
https://github.com/frappe/lms.git
synced 2026-05-06 07:29:32 +03:00
Compare commits
500 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38d8bbd2d3 | |||
| 6ff15117db | |||
| c831022a31 | |||
| dcbf91990a | |||
| 61bd858a69 | |||
| 721da94914 | |||
| 08baf1aaaf | |||
| 71b96d836a | |||
| a6abef224c | |||
| 6f0c695856 | |||
| 9d71915b7d | |||
| 29faf4d3b8 | |||
| b34a23ec48 | |||
| 5511576a65 | |||
| b40c6fe661 | |||
| 838f20758e | |||
| 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 | |||
| 44ff640a0c | |||
| 6085471053 | |||
| a72aa1366b | |||
| 108672fe3d | |||
| 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 | |||
| 31ba468f91 | |||
| 36028bf36b | |||
| 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 | |||
| 5933a59a14 | |||
| 5c3834cbbe | |||
| 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 | |||
| 2331ddfc67 | |||
| afe9674a6a | |||
| 5b22ef46c0 | |||
| 8f1604e237 | |||
| a9f4eb1291 | |||
| 26301c26e9 | |||
| 559338da59 | |||
| 9a7c77c57b | |||
| 63321fe2c8 | |||
| 68848fc642 | |||
| aa7ec019bc | |||
| eb33155db2 | |||
| 3088b14d83 | |||
| bf89f3ba2f | |||
| 2198adf902 | |||
| c5145c6c24 | |||
| 499bcd5281 | |||
| dc4bbdaa55 | |||
| bf19ebd3a8 | |||
| fa7e59b4ad | |||
| 5fcd3ddabe | |||
| a9dd43d0ea | |||
| 22e005f19c | |||
| 5a6a7ff646 | |||
| b3c8fbd833 | |||
| f828c76a0f | |||
| 2634a4e316 | |||
| fb0517caa0 | |||
| 90151be166 | |||
| 9b0a7f5fa5 | |||
| aa93375e6c | |||
| e8edf33be6 | |||
| 619f02a74b | |||
| 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 | |||
| 61d13aeb12 | |||
| 24bfe69985 | |||
| 7b2a4fe24a | |||
| 6f86b822bf | |||
| c3b2907ebf | |||
| 48c5b82c73 | |||
| af273a9a1c | |||
| 3b80ccd8db | |||
| 6484d551d1 | |||
| 719d7b5e88 | |||
| 3cd9d89f0b | |||
| 6e44da1993 | |||
| 44b7a210ce | |||
| 0e382f77ef | |||
| 7a1a247113 | |||
| 2c6ab3c331 | |||
| 79dba165c5 | |||
| d83f3464cd | |||
| e2ef8f732d | |||
| 919904a7f1 | |||
| 8453226f29 | |||
| 03759ca3c3 | |||
| c1608f8cc4 | |||
| 73b20653f0 | |||
| 7e683f8b44 | |||
| eba1815390 | |||
| 7564f0418b | |||
| 7e9cca2782 | |||
| dbc7e7d6d4 | |||
| ae25cfae6e | |||
| 970635430b | |||
| fe869a5988 | |||
| 7ea8040790 | |||
| 9f6f717585 | |||
| 641d729bd1 | |||
| ee73d8db86 | |||
| c7b5f9a04d | |||
| fa4c3a8ad7 | |||
| 71318bff04 | |||
| 186ddc93c8 | |||
| 2f1d9a8690 | |||
| 5fc7c52bfe | |||
| d0da6e7401 | |||
| a437c197a5 | |||
| 944fd6d013 | |||
| 80a9f2abe2 | |||
| c0298f0a70 | |||
| c30b21e5ae | |||
| 3e3afa63c2 | |||
| 7ef8aad2c8 | |||
| c00cb100a9 | |||
| f824ac3c28 | |||
| 2dea096fa0 | |||
| f1853a3c97 | |||
| 4995f8e3fd | |||
| f59eecd34e | |||
| 560ac8d5c4 | |||
| eab929da47 | |||
| d370ca796f | |||
| e9f0b12550 | |||
| a4035168be | |||
| 70872857d1 | |||
| 332334b556 | |||
| 1d91baa9c5 | |||
| 1e8040ef7b | |||
| ad6e0a3b80 | |||
| 8f6810923d | |||
| 990db83ab3 | |||
| 01f08ba449 | |||
| 7a3701cc10 | |||
| f021ddd84c | |||
| 0e3157c57e | |||
| 22eb8b9f3f | |||
| 9609398643 | |||
| cd0d4c413d | |||
| 1bbdff9aaf | |||
| 8754d0498c | |||
| 395ac52740 | |||
| 29cdbe5b8b | |||
| 0677c21dc7 | |||
| 1a58e2669f | |||
| 3fa27024f9 | |||
| 04c4069c75 | |||
| dd77b01ff1 | |||
| 085614bca6 | |||
| ef2606c41a | |||
| 1d95361587 | |||
| 6ead16edf0 | |||
| 31d21bf689 | |||
| c5ee140551 | |||
| 8e97b2f5bb | |||
| 19171a8019 | |||
| 3f49cf0c9c | |||
| 8f9cc536e2 | |||
| 573bc74a41 | |||
| bb2552b30c | |||
| 20ac312f57 | |||
| f58842438b | |||
| a34f99ed49 | |||
| 44b7243f75 | |||
| d2f7d80114 | |||
| 192b246381 | |||
| 17d9a3991e | |||
| 3407a02046 | |||
| be3546e79c | |||
| 556067de7a | |||
| 4ce08af516 | |||
| 8a4477a01f | |||
| 21d868a355 | |||
| 68a2cc1003 | |||
| 5a288836e0 | |||
| 4eab5e2867 | |||
| 6082093fb6 | |||
| 66c26c2a2c | |||
| f7eaf3faaa | |||
| 82a43b4f24 | |||
| 5bd33a1536 | |||
| a063c0735c | |||
| 732db8290d | |||
| 2b1d57f2bc | |||
| c8c051c1de | |||
| 13139bc2de | |||
| 9814abf55f | |||
| 249ecb8c4c | |||
| 582540e7f0 | |||
| 2f3fa7c295 | |||
| 3b49aac1b3 | |||
| dc25b408e6 | |||
| c8d9b97ab7 | |||
| 754d3cf2ca | |||
| e4268d0437 | |||
| 8febe21aa8 | |||
| 5384b26610 | |||
| 2a4650e5ed | |||
| 737993c543 | |||
| 58b49e3608 | |||
| 27553464d6 | |||
| be76268c70 | |||
| df2f2e6603 | |||
| fb1e1ec2e4 | |||
| ac81d1817b | |||
| da33e1d3bd | |||
| 24a511f48e | |||
| 14e669435f | |||
| 0407f01016 | |||
| 2a63f781ac | |||
| a882432702 | |||
| f8b6dfc981 | |||
| e8768d5687 |
@@ -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..."
|
||||
|
||||
@@ -3,9 +3,12 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- main-hotfix
|
||||
pull_request: {}
|
||||
jobs:
|
||||
tests:
|
||||
name: Server Tests
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -59,6 +62,9 @@ jobs:
|
||||
mkdir -p ~/bench-cache
|
||||
(cd && tar czf ~/bench-cache/bench.tgz frappe-bench)
|
||||
fi
|
||||
- name: add payments app to bench
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: bench get-app https://github.com/frappe/payments
|
||||
- name: add lms app to bench
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: bench get-app lms $GITHUB_WORKSPACE
|
||||
|
||||
@@ -18,9 +18,9 @@ jobs:
|
||||
owner: frappe
|
||||
repo: lms
|
||||
title: |-
|
||||
"chore: merge 'develop' into 'main'"
|
||||
"chore: merge 'main-hotfix' into 'main'"
|
||||
body: "Automated weekly release"
|
||||
base: main
|
||||
head: develop
|
||||
head: main-hotfix
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
@@ -4,7 +4,10 @@ on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- main-hotfix
|
||||
|
||||
permissions:
|
||||
# Do not change this as GITHUB_TOKEN is being used by roulette
|
||||
@@ -14,7 +17,6 @@ jobs:
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'frappe' }}
|
||||
timeout-minutes: 60
|
||||
|
||||
strategy:
|
||||
|
||||
+2
-1
@@ -13,4 +13,5 @@ package-lock.json
|
||||
lms/public/frontend
|
||||
lms/www/lms.html
|
||||
lms/www/_lms.html
|
||||
frappe-ui
|
||||
frappe-ui
|
||||
frappe-semgrep-rules
|
||||
@@ -0,0 +1,30 @@
|
||||
pull_request_rules:
|
||||
- name: backport to develop
|
||||
conditions:
|
||||
- label="backport develop"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- develop
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
|
||||
- name: backport to main-hotfix
|
||||
conditions:
|
||||
- label="backport main-hotfix"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- main-hotfix
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
|
||||
- name: backport to main
|
||||
conditions:
|
||||
- label="backport main"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- main
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
@@ -0,0 +1,2 @@
|
||||
ignore:
|
||||
- "**/test_helper.py"
|
||||
@@ -27,24 +27,24 @@ describe("Batch Creation", () => {
|
||||
cy.get("input[placeholder='Jane']").type(randomName);
|
||||
cy.get("button").contains("Add").click();
|
||||
|
||||
// Open Settings
|
||||
cy.get("span").contains("Learning").click();
|
||||
cy.get("span").contains("Settings").click();
|
||||
|
||||
// Add evaluator
|
||||
// Switch to Evaluators tab
|
||||
cy.get("[data-dismissable-layer]")
|
||||
.find("span")
|
||||
.contains(/^Evaluators$/)
|
||||
.click();
|
||||
|
||||
// Click "New" dropdown and select "New Evaluator"
|
||||
cy.get("[data-dismissable-layer]")
|
||||
.find("button")
|
||||
.contains("New")
|
||||
.click();
|
||||
const randomEvaluator = `evaluator${dateNow}@example.com`;
|
||||
cy.get("span").contains("New Evaluator").click();
|
||||
|
||||
const randomEvaluator = `evaluator${dateNow}@example.com`;
|
||||
cy.get("input[placeholder='jane@doe.com']").type(randomEvaluator);
|
||||
cy.get("input[placeholder='Jane']").type("Evaluator");
|
||||
cy.get("button").contains("Add").click();
|
||||
cy.wait(500);
|
||||
cy.get("div").contains(randomEvaluator).should("be.visible").click();
|
||||
|
||||
cy.visit("/lms/batches");
|
||||
@@ -54,25 +54,21 @@ describe("Batch Creation", () => {
|
||||
cy.get("button").contains("Create").click();
|
||||
cy.get("span").contains("New Batch").click();
|
||||
cy.wait(500);
|
||||
cy.url().should("include", "/batches/new/edit");
|
||||
cy.get("label").contains("Title").type("Test Batch");
|
||||
|
||||
cy.get("label").contains("Start Date").type("2030-10-01");
|
||||
cy.get("label").contains("End Date").type("2030-10-31");
|
||||
cy.get("label").contains("Start Time").type("10:00");
|
||||
cy.get("label").contains("End Time").type("11:00");
|
||||
cy.get("label").contains("Timezone").type("IST");
|
||||
cy.get("label").contains("Seat Count").type("10");
|
||||
cy.get("label").contains("Published").click();
|
||||
|
||||
cy.get("label")
|
||||
.contains("Short Description")
|
||||
.contains("Description")
|
||||
.type("Test Batch Short Description to test the UI");
|
||||
cy.get("div[contenteditable=true").invoke(
|
||||
"text",
|
||||
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||
);
|
||||
|
||||
/* Instructor */
|
||||
cy.get("label")
|
||||
.contains("Instructors")
|
||||
@@ -90,13 +86,14 @@ describe("Batch Creation", () => {
|
||||
cy.get("[id^=headlessui-combobox-option-").first().click();
|
||||
});
|
||||
});
|
||||
|
||||
cy.button("Save").click();
|
||||
cy.get("label").contains("Published").click();
|
||||
cy.button("Save").click();
|
||||
cy.wait(1000);
|
||||
let batchName;
|
||||
cy.url().then((url) => {
|
||||
console.log(url);
|
||||
batchName = url.split("/").pop();
|
||||
batchName = url.split("/").pop().split("#")[0];
|
||||
cy.wrap(batchName).as("batchName");
|
||||
});
|
||||
cy.wait(500);
|
||||
@@ -115,7 +112,7 @@ describe("Batch Creation", () => {
|
||||
.click();
|
||||
|
||||
cy.get("@batchName").then((batchName) => {
|
||||
cy.get(`a[href='/lms/batches/details/${batchName}'`).within(() => {
|
||||
cy.get(`a[href='/lms/batches/${batchName}'`).within(() => {
|
||||
cy.get("div").contains("Test Batch").should("be.visible");
|
||||
cy.get("div")
|
||||
.contains("Test Batch Short Description to test the UI")
|
||||
@@ -128,14 +125,11 @@ describe("Batch Creation", () => {
|
||||
.should("be.visible");
|
||||
cy.get("span").contains("IST").should("be.visible");
|
||||
cy.get("a").contains("Evaluator").should("be.visible");
|
||||
cy.get("div")
|
||||
.contains("10")
|
||||
.should("be.visible")
|
||||
.get("span")
|
||||
.contains("Seats Left")
|
||||
.should("be.visible");
|
||||
cy.contains("div:visible", "10 Seats Left").should(
|
||||
"be.visible"
|
||||
);
|
||||
});
|
||||
cy.get(`a[href='/lms/batches/details/${batchName}'`).click();
|
||||
cy.get(`a[href='/lms/batches/${batchName}'`).click();
|
||||
});
|
||||
|
||||
cy.get("div").contains("Test Batch").should("be.visible");
|
||||
@@ -157,18 +151,22 @@ describe("Batch Creation", () => {
|
||||
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||
)
|
||||
.should("be.visible");
|
||||
cy.get("button:visible").contains("Manage Batch").click();
|
||||
cy.get("button:visible").contains("Dashboard").click();
|
||||
|
||||
/* Add student to batch */
|
||||
cy.get("button").contains("Students").click();
|
||||
cy.get("button").contains("Add").click();
|
||||
cy.get('div[role="dialog"]').first().find("button").eq(1).click();
|
||||
cy.get("input[id^='headlessui-combobox-input-v-']").type(randomEmail);
|
||||
cy.get("button").contains("Enroll").click();
|
||||
cy.get('div[role="dialog"]')
|
||||
.first()
|
||||
.find("div[label='Student']")
|
||||
.find("div")
|
||||
.first()
|
||||
.click();
|
||||
cy.get("input[placeholder='Search']").type(randomEmail);
|
||||
cy.get("div").contains(randomEmail).click();
|
||||
cy.get("button").contains("Submit").click();
|
||||
|
||||
// Verify Seat Count
|
||||
cy.get("span").contains("Details").click();
|
||||
cy.get("button:visible").contains("Overview").click();
|
||||
cy.contains("div:visible", "9 Seats Left").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,10 +21,17 @@ describe("Course Creation", () => {
|
||||
"Test Course Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||
);
|
||||
|
||||
cy.fixture("profile.png", "base64").then((fileContent) => {
|
||||
cy.contains("Course Image")
|
||||
.should("exist")
|
||||
.parent()
|
||||
.find('input[type="file"]')
|
||||
.attachFile("profile.png", { force: true });
|
||||
|
||||
/* cy.fixture("profile.png", "base64").then((fileContent) => {
|
||||
expect(fileContent).to.exist;
|
||||
cy.get("div")
|
||||
.contains("Course Image")
|
||||
.siblings("div")
|
||||
.parent()
|
||||
.children('input[type="file"]')
|
||||
.attachFile({
|
||||
fileContent,
|
||||
@@ -32,7 +39,7 @@ describe("Course Creation", () => {
|
||||
mimeType: "image/png",
|
||||
encoding: "base64",
|
||||
});
|
||||
});
|
||||
}); */
|
||||
|
||||
/* Instructor */
|
||||
cy.get("label")
|
||||
@@ -53,7 +60,7 @@ describe("Course Creation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
cy.button("Create").last().click();
|
||||
cy.button("Save").last().click();
|
||||
|
||||
// Edit Course Details
|
||||
cy.wait(500);
|
||||
@@ -67,20 +74,17 @@ describe("Course Creation", () => {
|
||||
.within(() => {
|
||||
cy.get("button").click();
|
||||
});
|
||||
cy.get("[id^=headlessui-combobox-option-")
|
||||
.should("be.visible")
|
||||
.first()
|
||||
.click();
|
||||
cy.get("div").contains("Business").click();
|
||||
|
||||
cy.get("label").contains("Published").click();
|
||||
cy.get("label").contains("Published On").type("2021-01-01");
|
||||
cy.button("Save").click();
|
||||
|
||||
// Add Chapter
|
||||
cy.wait(1000);
|
||||
cy.wait(500);
|
||||
cy.button("Add").click();
|
||||
|
||||
cy.wait(1000);
|
||||
cy.wait(500);
|
||||
cy.get("[data-dismissable-layer]")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
@@ -89,12 +93,10 @@ describe("Course Creation", () => {
|
||||
});
|
||||
|
||||
// Add Lesson
|
||||
cy.wait(1000);
|
||||
cy.wait(500);
|
||||
cy.button("Add Lesson").click();
|
||||
cy.wait(1000);
|
||||
cy.wait(500);
|
||||
cy.url().should("include", "/learn/1-1/edit");
|
||||
cy.wait(1000);
|
||||
|
||||
cy.get("label").contains("Title").type("Test Lesson");
|
||||
cy.get("#content .ce-block").type(
|
||||
"{enter}This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||
@@ -102,21 +104,23 @@ describe("Course Creation", () => {
|
||||
cy.button("Save").click();
|
||||
|
||||
// View Course
|
||||
cy.wait(1000);
|
||||
cy.wait(500);
|
||||
cy.visit("/lms/courses");
|
||||
cy.closeOnboardingModal();
|
||||
|
||||
cy.url().should("include", "/lms/courses");
|
||||
cy.get(".grid a:first").within(() => {
|
||||
cy.get("div").contains("Test Course");
|
||||
cy.get("div").contains(
|
||||
"Test Course Short Introduction to test the UI"
|
||||
);
|
||||
cy.get(".bg-cover")
|
||||
.invoke("css", "background-image")
|
||||
.should("include", "/files/profile");
|
||||
});
|
||||
cy.get(".grid a:first").click();
|
||||
cy.get("div")
|
||||
.contains("Test Course")
|
||||
.closest("a")
|
||||
.within(() => {
|
||||
cy.get("div").contains(
|
||||
"Test Course Short Introduction to test the UI"
|
||||
);
|
||||
cy.get(".bg-cover")
|
||||
.invoke("css", "background-image")
|
||||
.should("include", "/files/profile");
|
||||
});
|
||||
cy.get("div").contains("Test Course").closest("a").click();
|
||||
cy.url().should("include", "/lms/courses/test-course");
|
||||
cy.get("div").contains("Test Course");
|
||||
cy.get("div").contains("Test Course Short Introduction to test the UI");
|
||||
@@ -134,7 +138,7 @@ describe("Course Creation", () => {
|
||||
cy.get("[id^=headlessui-disclosure-panel-").within(() => {
|
||||
cy.get("div").contains("Test Lesson").click();
|
||||
});
|
||||
cy.wait(3000);
|
||||
cy.wait(500);
|
||||
|
||||
// View Lesson
|
||||
cy.url().should("include", "/learn/1-1");
|
||||
@@ -145,7 +149,6 @@ describe("Course Creation", () => {
|
||||
);
|
||||
|
||||
// Add Discussion
|
||||
cy.get("span").contains("Community").click();
|
||||
cy.button("New Question").click();
|
||||
cy.wait(500);
|
||||
cy.get("[data-dismissable-layer]").within(() => {
|
||||
@@ -168,5 +171,16 @@ describe("Course Creation", () => {
|
||||
cy.get("div").contains(
|
||||
"This is a test comment. This will check if the UI is working properly."
|
||||
);
|
||||
|
||||
// Delete Course
|
||||
cy.get("div").contains("Test Course").click();
|
||||
cy.get("button").contains("Settings").click();
|
||||
cy.get("header").within(() => {
|
||||
cy.get("svg.lucide.lucide-trash2-icon").click();
|
||||
});
|
||||
cy.get("span").contains("Delete").click();
|
||||
cy.wait(500);
|
||||
cy.url().should("include", "/lms/courses");
|
||||
cy.get("div").contains("Test Course").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
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']
|
||||
|
||||
@@ -27,13 +27,11 @@
|
||||
"@editorjs/table": "2.4.2",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"ace-builds": "1.36.2",
|
||||
"apexcharts": "4.3.0",
|
||||
"chart.js": "4.4.1",
|
||||
"codemirror": "6.0.1",
|
||||
"dayjs": "1.11.10",
|
||||
"dompurify": "3.2.6",
|
||||
"feather-icons": "4.28.0",
|
||||
"frappe-ui": "^0.1.261",
|
||||
"frappe-ui": "^0.1.264",
|
||||
"highlight.js": "11.11.1",
|
||||
"lucide-vue-next": "0.383.0",
|
||||
"markdown-it": "14.0.0",
|
||||
@@ -51,12 +49,12 @@
|
||||
"vuedraggable": "4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "5.0.3",
|
||||
"@vitejs/plugin-vue": "5.0.3",
|
||||
"autoprefixer": "10.4.2",
|
||||
"postcss": "8.4.5",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"unplugin-auto-import": "^20.3.0",
|
||||
"vite": "5.0.11",
|
||||
"vite-plugin-pwa": "0.15.0"
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<FrappeUIProvider>
|
||||
<Layout class="isolate text-base">
|
||||
<Layout class="isolate text-p-base">
|
||||
<router-view />
|
||||
</Layout>
|
||||
<InstallPrompt v-if="isMobile && !settings.data?.disable_pwa" />
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
<template>
|
||||
<div v-if="batch?.data" class="">
|
||||
<div class="w-full flex items-center justify-between pb-4">
|
||||
<div class="font-medium text-ink-gray-7">
|
||||
{{ __('Statistics') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-5 mb-8">
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{ title: __('Students'), value: studentCount.data || 0 }"
|
||||
/>
|
||||
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: __('Certified'),
|
||||
value: certificationCount.data || 0,
|
||||
}"
|
||||
/>
|
||||
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: __('Courses'),
|
||||
value: batch?.data?.courses?.length || 0,
|
||||
}"
|
||||
/>
|
||||
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{ title: __('Assessments'), value: assessmentCount.data || 0 }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AxisChart
|
||||
v-if="showProgressChart"
|
||||
class="border"
|
||||
:config="{
|
||||
data: filteredChartData,
|
||||
title: __('Batch Summary'),
|
||||
subtitle: __('Progress of students in courses and assessments'),
|
||||
xAxis: {
|
||||
key: 'task',
|
||||
title: 'Tasks',
|
||||
type: 'category',
|
||||
},
|
||||
yAxis: {
|
||||
title: __('Number of Students'),
|
||||
echartOptions: {
|
||||
minInterval: 1,
|
||||
},
|
||||
},
|
||||
swapXY: true,
|
||||
series: [
|
||||
{
|
||||
name: 'value',
|
||||
type: 'bar',
|
||||
},
|
||||
],
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { AxisChart, createResource, NumberChart } from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
batch: { [key: string]: any } | null
|
||||
}>()
|
||||
|
||||
const studentCount = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
cache: ['batch_student_count', props.batch?.data?.name],
|
||||
params: {
|
||||
doctype: 'LMS Batch Enrollment',
|
||||
filters: { batch: props.batch?.data?.name },
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const assessmentCount = createResource({
|
||||
url: 'lms.lms.utils.get_batch_assessment_count',
|
||||
cache: ['batch_assessment_count', props.batch?.data?.name],
|
||||
params: {
|
||||
batch: props.batch?.data?.name,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const chartData = createResource({
|
||||
url: 'lms.lms.utils.get_batch_chart_data',
|
||||
cache: ['batch_chart_data', props.batch?.data?.name],
|
||||
params: { batch: props.batch?.data?.name },
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const certificationCount = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
cache: ['batch_certificate_count', props.batch?.data?.name],
|
||||
params: {
|
||||
doctype: 'LMS Certificate',
|
||||
filters: { batch_name: props.batch?.data?.name },
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const filteredChartData = computed(() =>
|
||||
(chartData.data || []).filter((item: { value: number }) => item.value > 0)
|
||||
)
|
||||
|
||||
const showProgressChart = computed(
|
||||
() =>
|
||||
studentCount.data &&
|
||||
(props.batch?.data?.courses?.length || assessmentCount.data)
|
||||
)
|
||||
</script>
|
||||
@@ -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)
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
{{ __('Submission by') }} {{ submissionResource.doc?.member_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-7 font-medium mb-2">
|
||||
{{ __('Question') }}:
|
||||
<div class="text-ink-gray-9 font-semibold mb-5">
|
||||
{{ __('Assignment Question') }}
|
||||
</div>
|
||||
<div
|
||||
v-html="assignment.data.question"
|
||||
@@ -42,7 +42,11 @@
|
||||
>
|
||||
{{ submissionResource.doc?.status }}
|
||||
</Badge>
|
||||
<Button variant="solid" @click="submitAssignment()">
|
||||
<Button
|
||||
v-if="canModifyAssignment || canGradeSubmission"
|
||||
variant="solid"
|
||||
@click="submitAssignment()"
|
||||
>
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -73,12 +77,15 @@
|
||||
}}
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!submissionResource.doc?.assignment_attachment"
|
||||
v-if="!attachment"
|
||||
:fileTypes="getType()"
|
||||
:uploadArgs="{
|
||||
private: true,
|
||||
}"
|
||||
:validateFile="validateFile"
|
||||
:validateFile="
|
||||
(file) =>
|
||||
validateFile(file, true, assignment.data.type.toLowerCase())
|
||||
"
|
||||
@success="(file) => saveSubmission(file)"
|
||||
>
|
||||
<template #default="{ uploading, progress, openFileSelector }">
|
||||
@@ -94,7 +101,7 @@
|
||||
<div v-else>
|
||||
<div class="flex items-center text-ink-gray-7">
|
||||
<a
|
||||
:href="submissionResource.doc.assignment_attachment"
|
||||
:href="attachment"
|
||||
target="_blank"
|
||||
class="cursor-pointer !no-underline text-sm leading-5"
|
||||
>
|
||||
@@ -103,11 +110,7 @@
|
||||
<FileText class="h-5 w-5 stroke-1.5" />
|
||||
</div>
|
||||
<span>
|
||||
{{
|
||||
submissionResource.doc.assignment_attachment
|
||||
.split('/')
|
||||
.pop()
|
||||
}}
|
||||
{{ attachment.split('/').pop() }}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
@@ -138,10 +141,11 @@
|
||||
@change="(val) => (answer = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
:readonly="!canModifyAssignment"
|
||||
:uploadArgs="{
|
||||
private: true,
|
||||
}"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -150,7 +154,7 @@
|
||||
user.data?.name == submissionResource.doc?.owner &&
|
||||
submissionResource.doc?.comments
|
||||
"
|
||||
class="mt-8 p-3 border rounded-lg"
|
||||
class="mt-8 p-3 border rounded-lg bg-surface-gray-2"
|
||||
>
|
||||
<div class="text-ink-gray-5 mb-4">
|
||||
{{ __('Comments by Evaluator') }}
|
||||
@@ -190,7 +194,7 @@
|
||||
:uploadArgs="{
|
||||
private: true,
|
||||
}"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -213,8 +217,10 @@ import {
|
||||
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { validateFile } from '@/utils'
|
||||
|
||||
const answer = ref(null)
|
||||
const attachment = ref(null)
|
||||
const comments = ref(null)
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
@@ -264,118 +270,97 @@ const assignment = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const newSubmission = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
let doc = {
|
||||
doctype: 'LMS Assignment Submission',
|
||||
assignment: props.assignmentID,
|
||||
member: user.data?.name,
|
||||
}
|
||||
if (!showUploader()) {
|
||||
doc.answer = answer.value
|
||||
}
|
||||
return {
|
||||
doc: doc,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const submissionResource = createDocumentResource({
|
||||
doctype: 'LMS Assignment Submission',
|
||||
name: props.submissionName,
|
||||
auto: false,
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
auto: false,
|
||||
cache: [user.data?.name, props.assignmentID],
|
||||
})
|
||||
|
||||
watch(submissionResource, () => {
|
||||
if (submissionResource.doc) {
|
||||
if (submissionResource.doc.answer) {
|
||||
answer.value = submissionResource.doc.answer
|
||||
}
|
||||
if (submissionResource.doc.comments) {
|
||||
comments.value = submissionResource.doc.comments
|
||||
}
|
||||
if (submissionResource.isDirty) {
|
||||
isDirty.value = true
|
||||
} else if (
|
||||
showUploader() &&
|
||||
!submissionResource.doc.assignment_attachment
|
||||
) {
|
||||
isDirty.value = true
|
||||
} else if (!showUploader() && !answer.value) {
|
||||
isDirty.value = true
|
||||
} else {
|
||||
isDirty.value = false
|
||||
}
|
||||
if (!submissionResource.doc) return
|
||||
if (submissionResource.doc.answer) {
|
||||
answer.value = submissionResource.doc.answer
|
||||
}
|
||||
if (submissionResource.doc.assignment_attachment) {
|
||||
attachment.value = submissionResource.doc.assignment_attachment
|
||||
}
|
||||
if (submissionResource.doc.comments) {
|
||||
comments.value = submissionResource.doc.comments
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => submissionResource.doc,
|
||||
() => {
|
||||
if (
|
||||
props.submissionName == 'new' &&
|
||||
submissionResource.doc?.assignment_attachment
|
||||
) {
|
||||
isDirty.value = true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const submitAssignment = () => {
|
||||
if (props.submissionName != 'new') {
|
||||
let evaluator =
|
||||
submissionResource.doc && submissionResource.doc.owner != user.data?.name
|
||||
? user.data?.name
|
||||
: null
|
||||
|
||||
submissionResource.setValue.submit(
|
||||
{
|
||||
...submissionResource.doc,
|
||||
evaluator: evaluator,
|
||||
comments: comments.value,
|
||||
answer: answer.value,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
isDirty.value = false
|
||||
toast.success(__('Changes saved successfully'))
|
||||
},
|
||||
}
|
||||
)
|
||||
updateSubmission()
|
||||
} else {
|
||||
addNewSubmission()
|
||||
}
|
||||
}
|
||||
|
||||
const addNewSubmission = () => {
|
||||
newSubmission.submit(
|
||||
{},
|
||||
let doc = {
|
||||
doctype: 'LMS Assignment Submission',
|
||||
assignment: props.assignmentID,
|
||||
member: user.data?.name,
|
||||
}
|
||||
if (!showUploader()) {
|
||||
doc.answer = answer.value
|
||||
} else {
|
||||
doc.assignment_attachment = attachment.value
|
||||
}
|
||||
call('frappe.client.insert', {
|
||||
doc: doc,
|
||||
})
|
||||
.then((data) => {
|
||||
toast.success(__('Assignment submitted successfully'))
|
||||
if (router.currentRoute.value.name == 'AssignmentSubmission') {
|
||||
router.push({
|
||||
name: 'AssignmentSubmission',
|
||||
params: {
|
||||
assignmentID: props.assignmentID,
|
||||
submissionName: data.name,
|
||||
},
|
||||
query: { fromLesson: router.currentRoute.value.query.fromLesson },
|
||||
})
|
||||
} else {
|
||||
markLessonProgress()
|
||||
router.go()
|
||||
}
|
||||
isDirty.value = false
|
||||
submissionResource.name = data.name
|
||||
submissionResource.reload()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
const updateSubmission = () => {
|
||||
let evaluator =
|
||||
submissionResource.doc && submissionResource.doc.owner != user.data?.name
|
||||
? user.data?.name
|
||||
: null
|
||||
|
||||
submissionResource.setValue.submit(
|
||||
{
|
||||
...submissionResource.doc,
|
||||
evaluator: evaluator,
|
||||
comments: comments.value,
|
||||
answer: answer.value,
|
||||
assignment_attachment: attachment.value,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
toast.success(__('Assignment submitted successfully'))
|
||||
if (router.currentRoute.value.name == 'AssignmentSubmission') {
|
||||
router.push({
|
||||
name: 'AssignmentSubmission',
|
||||
params: {
|
||||
assignmentID: props.assignmentID,
|
||||
submissionName: data.name,
|
||||
},
|
||||
query: { fromLesson: router.currentRoute.value.query.fromLesson },
|
||||
})
|
||||
} else {
|
||||
markLessonProgress()
|
||||
router.go()
|
||||
}
|
||||
submissionResource.name = data.name
|
||||
submissionResource.reload()
|
||||
isDirty.value = false
|
||||
toast.success(__('Changes saved successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -383,7 +368,7 @@ const addNewSubmission = () => {
|
||||
|
||||
const saveSubmission = (file) => {
|
||||
isDirty.value = true
|
||||
submissionResource.doc.assignment_attachment = file.file_url
|
||||
attachment.value = file.file_url
|
||||
}
|
||||
|
||||
const markLessonProgress = () => {
|
||||
@@ -417,24 +402,9 @@ const getType = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const validateFile = (file) => {
|
||||
let type = assignment.data?.type
|
||||
let extension = file.name.split('.').pop().toLowerCase()
|
||||
if (type == 'Image' && !['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||
return 'Only image file is allowed.'
|
||||
} else if (
|
||||
type == 'Document' &&
|
||||
!['doc', 'docx', 'xml'].includes(extension)
|
||||
) {
|
||||
return 'Only document file is allowed.'
|
||||
} else if (type == 'PDF' && !['pdf'].includes(extension)) {
|
||||
return 'Only PDF file is allowed.'
|
||||
}
|
||||
}
|
||||
|
||||
const removeSubmission = () => {
|
||||
isDirty.value = true
|
||||
submissionResource.doc.assignment_attachment = ''
|
||||
attachment.value = null
|
||||
}
|
||||
|
||||
const canGradeSubmission = computed(() => {
|
||||
@@ -448,11 +418,15 @@ const canGradeSubmission = computed(() => {
|
||||
})
|
||||
|
||||
const canModifyAssignment = computed(() => {
|
||||
return (
|
||||
!submissionResource.doc ||
|
||||
(submissionResource.doc?.owner == user.data?.name &&
|
||||
submissionResource.doc?.status == 'Not Graded')
|
||||
)
|
||||
if (props.submissionName == 'new') {
|
||||
return true
|
||||
} else if (
|
||||
submissionResource.doc?.owner == user.data?.name &&
|
||||
submissionResource.doc?.status == 'Not Graded'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const submissionStatusOptions = computed(() => {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-10">
|
||||
<UpcomingEvaluations
|
||||
:batch="batch.data.name"
|
||||
:endDate="batch.data.evaluation_end_date"
|
||||
:courses="batch.data.courses"
|
||||
/>
|
||||
<Assessments :batch="batch.data.name" />
|
||||
<!-- <StudentHeatmap /> -->
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||
import Assessments from '@/components/Assessments.vue'
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
isStudent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,226 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-ink-gray-9 font-medium">
|
||||
{{ studentCount.data ?? 0 }} {{ __('Students') }}
|
||||
</div>
|
||||
<Button v-if="!readOnlyMode" @click="openStudentModal()">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="students.data?.length">
|
||||
<ListView
|
||||
class="max-h-[75vh]"
|
||||
:columns="studentColumns"
|
||||
:rows="students.data"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem
|
||||
:item="item"
|
||||
v-for="item in studentColumns"
|
||||
:title="item.label"
|
||||
>
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon
|
||||
v-if="item.icon"
|
||||
:name="item.icon"
|
||||
class="h-4 w-4 stroke-1.5"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow
|
||||
:row="row"
|
||||
v-for="row in students.data"
|
||||
class="group cursor-pointer hover:bg-surface-gray-2 rounded"
|
||||
@click="openStudentProgressModal(row)"
|
||||
>
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem
|
||||
:item="row[column.key]"
|
||||
:align="column.align"
|
||||
class="text-sm"
|
||||
>
|
||||
<template #prefix>
|
||||
<div v-if="column.key == 'full_name'">
|
||||
<Avatar
|
||||
class="flex items-center"
|
||||
:image="row['user_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="column.key == 'progress'"
|
||||
class="flex items-center space-x-4 w-full"
|
||||
>
|
||||
<ProgressBar :progress="row[column.key]" size="sm" />
|
||||
<div class="text-xs">{{ row[column.key] }}%</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="removeStudents(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
<div class="mt-4 flex justify-center" v-if="students.hasNextPage">
|
||||
<Button @click="students.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</ListView>
|
||||
</div>
|
||||
<div v-else-if="!students.loading" class="text-sm italic text-ink-gray-5">
|
||||
{{ __('There are no students in this batch.') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StudentModal
|
||||
:batch="props.batch.data.name"
|
||||
v-model="showStudentModal"
|
||||
v-model:reloadStudents="students"
|
||||
v-model:batchModal="props.batch"
|
||||
/>
|
||||
<BatchStudentProgress
|
||||
:student="selectedStudent"
|
||||
v-model="showStudentProgressModal"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
createListResource,
|
||||
createResource,
|
||||
FeatherIcon,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListSelectBanner,
|
||||
ListRow,
|
||||
ListRows,
|
||||
ListView,
|
||||
ListRowItem,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
import StudentModal from '@/components/Modals/StudentModal.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
|
||||
|
||||
const showStudentModal = ref(false)
|
||||
const showStudentProgressModal = ref(false)
|
||||
const selectedStudent = ref(null)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const studentCount = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
cache: ['batch_student_count', props.batch?.data?.name],
|
||||
params: {
|
||||
doctype: 'LMS Batch Enrollment',
|
||||
filters: { batch: props.batch?.data?.name },
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const students = createListResource({
|
||||
doctype: 'LMS Batch Enrollment',
|
||||
url: 'lms.lms.utils.get_batch_students',
|
||||
cache: ['batch_students', props.batch?.data?.name],
|
||||
pageLength: 50,
|
||||
filters: {
|
||||
batch: props.batch?.data?.name,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const studentColumns = [
|
||||
{
|
||||
label: 'Full Name',
|
||||
key: 'full_name',
|
||||
width: '25rem',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
label: 'Progress',
|
||||
key: 'progress',
|
||||
width: '15rem',
|
||||
icon: 'activity',
|
||||
},
|
||||
{
|
||||
label: 'Last Active',
|
||||
key: 'last_active',
|
||||
width: '10rem',
|
||||
align: 'center',
|
||||
icon: 'clock',
|
||||
},
|
||||
]
|
||||
|
||||
const openStudentModal = () => {
|
||||
showStudentModal.value = true
|
||||
}
|
||||
|
||||
const openStudentProgressModal = (row) => {
|
||||
showStudentProgressModal.value = true
|
||||
selectedStudent.value = row
|
||||
}
|
||||
|
||||
const deleteStudents = createResource({
|
||||
url: 'lms.lms.api.delete_documents',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Batch Enrollment',
|
||||
documents: values.students,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const removeStudents = (selections, unselectAll) => {
|
||||
deleteStudents.submit(
|
||||
{
|
||||
students: Array.from(selections),
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
students.reload()
|
||||
studentCount.reload()
|
||||
props.batch.reload()
|
||||
toast.success(__('Students deleted successfully'))
|
||||
unselectAll()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -9,14 +9,23 @@
|
||||
nullable
|
||||
v-slot="{ open: isComboboxOpen }"
|
||||
>
|
||||
<Popover class="w-full" v-model:show="showOptions">
|
||||
<Popover
|
||||
class="w-full"
|
||||
v-model:show="showOptions"
|
||||
:matchTargetWidth="true"
|
||||
>
|
||||
<template #target="{ open: openPopover, togglePopover }">
|
||||
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
||||
<div class="w-full">
|
||||
<button
|
||||
class="flex w-full items-center justify-between focus:outline-none"
|
||||
:class="inputClasses"
|
||||
@click="() => togglePopover()"
|
||||
@click="
|
||||
() => {
|
||||
showOptions = !showOptions
|
||||
togglePopover()
|
||||
}
|
||||
"
|
||||
:disabled="attrs.readonly"
|
||||
>
|
||||
<div class="flex items-center w-[90%]">
|
||||
@@ -87,7 +96,8 @@
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex items-center rounded px-2.5 py-2 text-base',
|
||||
'flex items-center rounded px-2.5 text-base py-1.5',
|
||||
optionLines(option).secondary ? '' : 'h-7',
|
||||
{ 'bg-surface-gray-2': active },
|
||||
]"
|
||||
>
|
||||
@@ -99,18 +109,21 @@
|
||||
name="item-label"
|
||||
v-bind="{ active, selected, option }"
|
||||
>
|
||||
<div class="flex flex-col space-y-1 text-ink-gray-8">
|
||||
<div>
|
||||
{{ option.label }}
|
||||
<div
|
||||
class="flex flex-col px-1"
|
||||
:class="
|
||||
optionLines(option).secondary ? 'gap-0.5' : ''
|
||||
"
|
||||
>
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
{{ optionLines(option).primary }}
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
option.description &&
|
||||
option.description != option.label
|
||||
"
|
||||
class="text-xs text-ink-gray-7"
|
||||
v-html="option.description"
|
||||
></div>
|
||||
v-if="optionLines(option).secondary"
|
||||
class="text-sm text-ink-gray-5"
|
||||
>
|
||||
{{ optionLines(option).secondary }}
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
</li>
|
||||
@@ -120,7 +133,7 @@
|
||||
v-if="groups.length == 0"
|
||||
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
|
||||
>
|
||||
No results found
|
||||
{{ __('No results found') }}
|
||||
</li>
|
||||
</ComboboxOptions>
|
||||
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
|
||||
@@ -241,6 +254,17 @@ function filterOptions(options) {
|
||||
})
|
||||
}
|
||||
|
||||
function optionLines(option) {
|
||||
const primary = option.label
|
||||
let secondary = null
|
||||
if (option.description && option.description !== primary) {
|
||||
secondary = option.description
|
||||
} else if (option.value && option.value !== primary) {
|
||||
secondary = option.value
|
||||
}
|
||||
return { primary, secondary }
|
||||
}
|
||||
|
||||
function displayValue(option) {
|
||||
if (typeof option === 'string') {
|
||||
let allOptions = groups.value.flatMap((group) => group.items)
|
||||
@@ -284,9 +308,9 @@ const inputClasses = computed(() => {
|
||||
let variant = props.disabled ? 'disabled' : props.variant
|
||||
let variantClasses = {
|
||||
subtle:
|
||||
'border border-gray-100 bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
|
||||
'border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus-within:bg-surface-white focus-within:border-outline-gray-4 focus-within:shadow-sm focus-within:ring-0 focus-within:ring-2 focus-within:ring-outline-gray-3',
|
||||
outline:
|
||||
'border border-outline-gray-2 bg-surface-white placeholder-ink-gray-4 hover:border-outline-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
|
||||
'border border-outline-gray-2 bg-surface-white placeholder-ink-gray-4 hover:border-outline-gray-3 hover:shadow-sm focus-within:bg-surface-white focus-within:border-outline-gray-4 focus-within:shadow-sm focus-within:ring-0 focus-within:ring-2 focus-within:ring-outline-gray-3',
|
||||
disabled: [
|
||||
'border bg-surface-menu-bar placeholder-ink-gray-3',
|
||||
props.variant === 'outline'
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
<div class="text-xs text-ink-gray-5 mb-2">
|
||||
{{ label }}
|
||||
</div>
|
||||
<div class="overflow-visible border rounded-md">
|
||||
<div class="overflow-visible border border-outline-gray-modals rounded-md">
|
||||
<div class="overflow-x-auto">
|
||||
<div
|
||||
class="grid items-center space-x-4 p-2 border-b"
|
||||
class="grid items-center space-x-4 p-2 border-b border-outline-gray-modals"
|
||||
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
||||
>
|
||||
<div
|
||||
@@ -28,7 +28,7 @@
|
||||
<input
|
||||
v-if="showKey(key)"
|
||||
v-model="row[key]"
|
||||
class="py-1.5 px-2 border-none focus:ring-0 focus:border focus:border-gray-300 focus:bg-surface-gray-2 rounded-md text-sm focus:outline-none"
|
||||
class="py-1.5 px-2 w-full border-none bg-transparent text-ink-gray-8 focus:ring-0 focus:border focus:border-outline-gray-3 focus:bg-surface-gray-2 rounded-md text-sm focus:outline-none"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<div
|
||||
v-if="menuOpenIndex === rowIndex"
|
||||
ref="menuRef"
|
||||
class="absolute right-0 w-32 z-50 bg-surface-white border border-outline-gray-1 rounded-md shadow-sm"
|
||||
class="absolute right-0 w-32 z-50 bg-surface-modal border border-outline-gray-modals rounded-md shadow-sm"
|
||||
:class="
|
||||
rowIndex == (rows?.length ?? 0) - 1
|
||||
? 'bottom-full mb-1'
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
:size="attrs.size || 'sm'"
|
||||
:variant="attrs.variant"
|
||||
:placeholder="attrs.placeholder"
|
||||
:filterable="false"
|
||||
:readonly="attrs.readonly"
|
||||
>
|
||||
<template #target="{ open, togglePopover }">
|
||||
@@ -31,28 +30,48 @@
|
||||
</template>
|
||||
|
||||
<template #footer="{ value, close }">
|
||||
<div v-if="attrs.onCreate">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Create New')"
|
||||
@click="attrs.onCreate(value, close)"
|
||||
<div v-if="creating" class="flex items-center gap-1">
|
||||
<button
|
||||
class="p-1 rounded hover:bg-surface-gray-3 text-ink-gray-5"
|
||||
@click="creating = false"
|
||||
:aria-label="__('Cancel')"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
<ArrowLeft class="size-4 stroke-1.5" />
|
||||
</button>
|
||||
<FormControl
|
||||
v-model="newItemName"
|
||||
class="flex-1 min-w-0"
|
||||
size="sm"
|
||||
:placeholder="__(props.inlineCreatePlaceholder)"
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
:disabled="!newItemName.trim()"
|
||||
@click="submitCreate"
|
||||
:aria-label="__('Create')"
|
||||
>
|
||||
{{ __('Create') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div v-else class="flex justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Clear')"
|
||||
@click="() => clearValue(close)"
|
||||
:aria-label="__('Clear')"
|
||||
>
|
||||
{{ __('Clear') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="props.onCreate"
|
||||
variant="ghost"
|
||||
@click="handleCreate(close)"
|
||||
:aria-label="__('Create New')"
|
||||
>
|
||||
<template #prefix>
|
||||
<X class="h-4 w-4 stroke-1.5" />
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Create New') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -64,8 +83,8 @@
|
||||
<script setup>
|
||||
import Autocomplete from '@/components/Controls/Autocomplete.vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import { createResource, Button, FormControl } from 'frappe-ui'
|
||||
import { Plus, ArrowLeft } from 'lucide-vue-next'
|
||||
import { useAttrs, computed, ref } from 'vue'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
|
||||
@@ -86,18 +105,32 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inlineCreate: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inlineCreatePlaceholder: {
|
||||
type: String,
|
||||
default: 'Enter...',
|
||||
},
|
||||
onCreate: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
const attrs = useAttrs()
|
||||
const valuePropPassed = computed(() => 'value' in attrs)
|
||||
const creating = ref(false)
|
||||
const newItemName = ref('')
|
||||
|
||||
const value = computed({
|
||||
get: () => (valuePropPassed.value ? attrs.value : props.modelValue),
|
||||
set: (val) => {
|
||||
return (
|
||||
val?.value &&
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val?.value)
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val.value)
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -106,6 +139,26 @@ const autocomplete = ref(null)
|
||||
const text = ref('')
|
||||
const settingsStore = useSettings()
|
||||
|
||||
function handleCreate(close) {
|
||||
if (props.inlineCreate) {
|
||||
creating.value = true
|
||||
return
|
||||
}
|
||||
if (props.onCreate) {
|
||||
props.onCreate(null, close)
|
||||
}
|
||||
}
|
||||
|
||||
function submitCreate() {
|
||||
if (!newItemName.value.trim() || !props.onCreate) return
|
||||
const value = newItemName.value.trim()
|
||||
props.onCreate(value, () => {
|
||||
creating.value = false
|
||||
newItemName.value = ''
|
||||
reload()
|
||||
})
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
() => autocomplete.value?.query,
|
||||
(val) => {
|
||||
@@ -141,7 +194,7 @@ const options = createResource({
|
||||
params: {
|
||||
txt: text.value,
|
||||
doctype: props.doctype,
|
||||
filters: props.filters,
|
||||
filters: JSON.stringify(props.filters),
|
||||
},
|
||||
transform: (data) => {
|
||||
return data.map((option) => {
|
||||
@@ -154,12 +207,12 @@ const options = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const reload = (val) => {
|
||||
const reload = (val = '') => {
|
||||
options.update({
|
||||
params: {
|
||||
txt: val,
|
||||
doctype: props.doctype,
|
||||
filters: props.filters,
|
||||
filters: JSON.stringify(props.filters),
|
||||
},
|
||||
})
|
||||
options.reload()
|
||||
@@ -179,4 +232,6 @@ const labelClasses = computed(() => {
|
||||
'text-ink-gray-5',
|
||||
]
|
||||
})
|
||||
|
||||
defineExpose({ reload })
|
||||
</script>
|
||||
|
||||
@@ -1,177 +1,144 @@
|
||||
<template>
|
||||
<div>
|
||||
<label class="block mb-1" :class="labelClasses" v-if="label">
|
||||
<label v-if="label" class="block mb-1" :class="labelClasses">
|
||||
{{ label }}
|
||||
<span class="text-ink-red-3" v-if="required">*</span>
|
||||
<span v-if="required" class="text-ink-red-3">*</span>
|
||||
</label>
|
||||
<div class="w-full">
|
||||
<Combobox v-model="selectedValue" nullable>
|
||||
<Popover class="w-full" v-model:show="showOptions">
|
||||
<template #target="{ togglePopover }">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="search-input form-input w-full focus-visible:!ring-0"
|
||||
type="text"
|
||||
:value="query"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value
|
||||
showOptions = true
|
||||
}
|
||||
"
|
||||
@click="
|
||||
(e) => {
|
||||
showOptions = true
|
||||
nextTick(() => {
|
||||
setFocus()
|
||||
})
|
||||
}
|
||||
"
|
||||
@focus="
|
||||
() => {
|
||||
if (!filterOptions.data || filterOptions.data.length === 0) {
|
||||
reload('')
|
||||
}
|
||||
}
|
||||
"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</template>
|
||||
<template #body="{ isOpen, close }">
|
||||
<div v-show="isOpen">
|
||||
<div
|
||||
class="flex flex-col mt-1 rounded-lg bg-surface-white py-1 text-base border-2 max-h-[13rem]"
|
||||
<Combobox v-model="selectedValue" nullable v-slot="{ open }">
|
||||
<div class="relative w-full">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-1.5 w-full rounded-lg border border-[--surface-gray-2] bg-surface-gray-2 px-2 py-1.5 cursor-text transition-colors focus-within:bg-surface-white focus-within:border-outline-gray-4 focus-within:shadow-sm focus-within:ring-0 focus-within:ring-2 focus-within:ring-outline-gray-3"
|
||||
@click="focusInput"
|
||||
>
|
||||
<button
|
||||
v-for="value in values"
|
||||
:key="value"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 bg-surface-white border border-outline-gray-2 text-ink-gray-7 pl-2 pr-1.5 py-0.5 rounded text-base leading-5"
|
||||
@click.stop="removeValue(value)"
|
||||
>
|
||||
<span>{{ value }}</span>
|
||||
<X class="size-3.5 stroke-1.5 shrink-0" />
|
||||
</button>
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="flex-1 min-w-[4rem] border-none outline-none bg-transparent p-0 text-base focus:ring-0"
|
||||
type="text"
|
||||
:placeholder="!values?.length ? __('Search...') : ''"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value
|
||||
}
|
||||
"
|
||||
autocomplete="off"
|
||||
@focus="onFocus"
|
||||
/>
|
||||
</div>
|
||||
<ComboboxButton ref="trigger" class="hidden" />
|
||||
<ComboboxOptions
|
||||
v-show="open"
|
||||
static
|
||||
class="absolute z-20 mt-1 w-full rounded-lg bg-surface-modal border-2 border-outline-gray-modals max-h-[13rem] flex flex-col"
|
||||
>
|
||||
<div
|
||||
class="flex-1 my-1 overflow-y-auto px-1.5"
|
||||
:class="options.length ? 'min-h-[6rem]' : 'min-h-[1rem]'"
|
||||
>
|
||||
<template v-if="options.length">
|
||||
<ComboboxOption
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option"
|
||||
v-slot="{ active }"
|
||||
>
|
||||
<ComboboxOptions
|
||||
class="flex-1 my-1 overflow-y-auto px-1.5"
|
||||
:class="options.length ? 'min-h-[6rem]' : 'min-h-[3.8rem]'"
|
||||
static
|
||||
<li
|
||||
:class="[
|
||||
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
|
||||
{ 'bg-surface-gray-2': active },
|
||||
]"
|
||||
>
|
||||
<ComboboxOption
|
||||
v-if="options.length"
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option"
|
||||
v-slot="{ active }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
|
||||
{ 'bg-surface-gray-2': active },
|
||||
]"
|
||||
>
|
||||
<div class="flex flex-col gap-1 p-1">
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
{{
|
||||
option.value == option.label
|
||||
? option.description
|
||||
: option.label
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
<div v-else class="text-ink-gray-7 px-4">
|
||||
{{ __('No results found') }}
|
||||
<div class="flex flex-col gap-1 p-1">
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
{{
|
||||
option.value === option.label
|
||||
? option.description
|
||||
: option.label
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</ComboboxOptions>
|
||||
<div v-if="attrs.onCreate" class="px-1 pt-2 bg-white border-t">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Create New')"
|
||||
@click="attrs.onCreate(close)"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</template>
|
||||
|
||||
<div v-else class="text-ink-gray-7 px-4 py-2">
|
||||
{{ __('No results found') }}
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Combobox>
|
||||
</div>
|
||||
<div v-if="values.length" class="grid grid-cols-2 gap-2 mt-1">
|
||||
<div
|
||||
v-for="value in values"
|
||||
class="flex items-center justify-between break-all bg-surface-gray-2 text-ink-gray-7 word-wrap p-2 rounded-md mr-2"
|
||||
>
|
||||
<span class="break-all">
|
||||
{{ value }}
|
||||
</span>
|
||||
<X
|
||||
class="size-4 stroke-1.5 cursor-pointer"
|
||||
@click="removeValue(value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="attrs.onCreate"
|
||||
class="p-1 bg-surface-white border-t rounded-b-lg"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Create New')"
|
||||
@click="attrs.onCreate()"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> -->
|
||||
</Combobox>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxButton,
|
||||
ComboboxInput,
|
||||
ComboboxOptions,
|
||||
ComboboxOption,
|
||||
} from '@headlessui/vue'
|
||||
import { createResource, Popover, Button } from 'frappe-ui'
|
||||
import { ref, computed, nextTick, useAttrs } from 'vue'
|
||||
import { set, watchDebounced } from '@vueuse/core'
|
||||
import { createResource, Button, toast } from 'frappe-ui'
|
||||
import { ref, computed, useAttrs, watch } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { X, Plus } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'sm',
|
||||
},
|
||||
doctype: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
validate: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
label: String,
|
||||
size: { type: String, default: 'sm' },
|
||||
doctype: { type: String, required: true },
|
||||
filters: { type: [Object, Array], default: () => ({}) },
|
||||
url: { type: String, default: 'frappe.desk.search.search_link' },
|
||||
searchParams: { type: Object, default: () => ({}) },
|
||||
validate: Function,
|
||||
errorMessage: {
|
||||
type: Function,
|
||||
default: (value) => `${value} is an Invalid value`,
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
},
|
||||
required: Boolean,
|
||||
})
|
||||
|
||||
const values = defineModel()
|
||||
const values = defineModel({ default: () => [] })
|
||||
const attrs = useAttrs()
|
||||
const search = ref(null)
|
||||
const error = ref(null)
|
||||
const trigger = ref(null)
|
||||
const query = ref('')
|
||||
const text = ref('')
|
||||
const showOptions = ref(false)
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const selectedValue = ref(null)
|
||||
|
||||
const selectedValue = computed({
|
||||
get: () => query.value || '',
|
||||
set: (val) => {
|
||||
query.value = ''
|
||||
val?.value && addValue(val.value)
|
||||
showOptions.value = false
|
||||
emit('update:modelValue', values.value)
|
||||
},
|
||||
watch(selectedValue, (val) => {
|
||||
if (!val?.value) return
|
||||
query.value = ''
|
||||
addValue(val.value)
|
||||
selectedValue.value = null
|
||||
})
|
||||
|
||||
watchDebounced(
|
||||
@@ -185,79 +152,77 @@ watchDebounced(
|
||||
{ debounce: 300, immediate: true }
|
||||
)
|
||||
|
||||
const filterOptions = createResource({
|
||||
url: 'frappe.desk.search.search_link',
|
||||
method: 'POST',
|
||||
cache: [text.value, props.doctype],
|
||||
auto: true,
|
||||
params: {
|
||||
txt: text.value,
|
||||
doctype: props.doctype,
|
||||
// Refetch when filters or searchParams change
|
||||
watch(
|
||||
() => [props.filters, props.searchParams],
|
||||
() => {
|
||||
reload(text.value)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
function getParams(txt) {
|
||||
return {
|
||||
txt,
|
||||
doctype: props.doctype,
|
||||
filters: JSON.stringify(props.filters),
|
||||
...props.searchParams,
|
||||
}
|
||||
}
|
||||
|
||||
const filterOptions = createResource({
|
||||
url: props.url,
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
setFocus()
|
||||
const allOptions = filterOptions.data || []
|
||||
return allOptions.filter((option) => !values.value?.includes(option.value))
|
||||
})
|
||||
|
||||
function reload(val) {
|
||||
filterOptions.update({
|
||||
params: {
|
||||
txt: val,
|
||||
doctype: props.doctype,
|
||||
},
|
||||
params: getParams(val),
|
||||
})
|
||||
filterOptions.reload()
|
||||
}
|
||||
|
||||
const addValue = (value) => {
|
||||
error.value = null
|
||||
if (value) {
|
||||
const splitValues = value.split(',')
|
||||
splitValues.forEach((value) => {
|
||||
value = value.trim()
|
||||
if (value) {
|
||||
// check if value is not already in the values array
|
||||
if (!values.value?.includes(value)) {
|
||||
// check if value is valid
|
||||
if (value && props.validate && !props.validate(value)) {
|
||||
error.value = props.errorMessage(value)
|
||||
return
|
||||
}
|
||||
// add value to values array
|
||||
if (!values.value) {
|
||||
values.value = [value]
|
||||
} else {
|
||||
values.value.push(value)
|
||||
}
|
||||
value = value.replace(value, '')
|
||||
}
|
||||
}
|
||||
})
|
||||
!error.value && (value = '')
|
||||
function onFocus() {
|
||||
if (!filterOptions.data?.length) {
|
||||
reload('')
|
||||
}
|
||||
trigger.value?.$el.click()
|
||||
}
|
||||
|
||||
const removeValue = (value) => {
|
||||
function addValue(value) {
|
||||
if (!value) return
|
||||
|
||||
const splitValues = value.split(',')
|
||||
let newValues = [...(values.value || [])]
|
||||
|
||||
splitValues.forEach((val) => {
|
||||
val = val.trim()
|
||||
|
||||
if (!val) return
|
||||
if (newValues.includes(val)) return
|
||||
|
||||
if (props.validate && !props.validate(val)) {
|
||||
toast.error(props.errorMessage(val))
|
||||
return
|
||||
}
|
||||
|
||||
newValues.push(val)
|
||||
})
|
||||
|
||||
values.value = newValues
|
||||
}
|
||||
|
||||
function removeValue(value) {
|
||||
values.value = values.value.filter((v) => v !== value)
|
||||
emit('update:modelValue', values.value)
|
||||
}
|
||||
|
||||
function setFocus() {
|
||||
search.value.$el.focus()
|
||||
}
|
||||
|
||||
defineExpose({ setFocus })
|
||||
|
||||
const labelClasses = computed(() => {
|
||||
return [
|
||||
{
|
||||
sm: 'text-xs',
|
||||
md: 'text-base',
|
||||
}[props.size || 'sm'],
|
||||
'text-ink-gray-5',
|
||||
]
|
||||
})
|
||||
const labelClasses = computed(() => [
|
||||
{ sm: 'text-xs', md: 'text-base' }[props.size || 'sm'],
|
||||
'text-ink-gray-5',
|
||||
])
|
||||
</script>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
'border object-cover',
|
||||
shape === 'circle'
|
||||
? 'w-20 h-20 rounded-full'
|
||||
: 'w-44 h-auto min-h-20 rounded-md',
|
||||
: 'w-44 h-auto min-h-20 max-h-32 rounded-md',
|
||||
]"
|
||||
/>
|
||||
<video v-else controls class="border rounded-md w-44 h-auto">
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{{ course.data.price }}
|
||||
</div>
|
||||
<div v-if="!readOnlyMode">
|
||||
<div v-if="course.data.membership" class="space-y-2">
|
||||
<div v-if="course.data.membership" class="space-y-2 mb-8">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Lesson',
|
||||
@@ -46,7 +46,7 @@
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" size="md" class="w-full">
|
||||
<Button variant="solid" size="md" class="w-full mb-8">
|
||||
<template #prefix>
|
||||
<CreditCard class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
@@ -67,7 +67,7 @@
|
||||
v-else-if="!isAdmin"
|
||||
@click="enrollStudent()"
|
||||
variant="solid"
|
||||
class="w-full"
|
||||
class="w-full mb-8"
|
||||
size="md"
|
||||
>
|
||||
<template #prefix>
|
||||
@@ -90,24 +90,26 @@
|
||||
{{ __('Get Certificate') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class="font-medium text-ink-gray-9"
|
||||
:class="{ 'mt-8': course.data.membership && !readOnlyMode }"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="font-medium text-ink-gray-9">
|
||||
{{ __('This course has:') }}
|
||||
</div>
|
||||
<div class="flex items-center text-ink-gray-9">
|
||||
<BookOpen class="h-4 w-4 stroke-1.5" />
|
||||
<span class="ml-2">
|
||||
{{ course.data.lessons }} {{ __('Lessons') }}
|
||||
{{ course.data.lessons }}
|
||||
{{ course.data.lessons > 1 ? __('lessons') : __('lesson') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center text-ink-gray-9">
|
||||
<Users class="h-4 w-4 stroke-1.5" />
|
||||
<span class="ml-2">
|
||||
{{ formatAmount(course.data.enrollments) }}
|
||||
{{ __('Enrolled Students') }}
|
||||
{{
|
||||
course.data.enrollments > 1
|
||||
? __('enrolled students')
|
||||
: __('enrolled student')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -116,7 +118,7 @@
|
||||
>
|
||||
<Star class="size-4 stroke-1.5 fill-yellow-500 text-transparent" />
|
||||
<span class="ml-2">
|
||||
{{ course.data.rating }} {{ __('Rating') }}
|
||||
{{ course.data.rating }} {{ __('average rating') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -75,6 +75,12 @@
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Check
|
||||
v-if="
|
||||
chapter.is_scorm_package && isScormChapterComplete(chapter)
|
||||
"
|
||||
class="h-4 w-4 text-green-700"
|
||||
/>
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel v-if="!chapter.is_scorm_package">
|
||||
<Draggable
|
||||
@@ -112,6 +118,14 @@
|
||||
v-else-if="lesson.icon === 'icon-quiz'"
|
||||
class="h-4 w-4 stroke-1 mr-2"
|
||||
/>
|
||||
<NotebookPen
|
||||
v-else-if="lesson.icon === 'icon-assignment'"
|
||||
class="h-4 w-4 stroke-1 mr-2"
|
||||
/>
|
||||
<SquareCode
|
||||
v-else-if="lesson.icon === 'icon-code'"
|
||||
class="h-4 w-4 stroke-1 mr-2"
|
||||
/>
|
||||
<FileText
|
||||
v-else-if="lesson.icon === 'icon-list'"
|
||||
class="h-4 w-4 text-ink-gray-9 stroke-1 mr-2"
|
||||
@@ -177,7 +191,9 @@ import {
|
||||
FilePenLine,
|
||||
HelpCircle,
|
||||
MonitorPlay,
|
||||
NotebookPen,
|
||||
Plus,
|
||||
SquareCode,
|
||||
Trash2,
|
||||
} from 'lucide-vue-next'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -391,6 +407,10 @@ const redirectToChapter = (chapter) => {
|
||||
})
|
||||
}
|
||||
|
||||
const isScormChapterComplete = (chapter) => {
|
||||
return chapter.lessons?.length && chapter.lessons.every((l) => l.is_complete)
|
||||
}
|
||||
|
||||
const isActiveLesson = (lessonNumber) => {
|
||||
return (
|
||||
route.params.chapterNumber == lessonNumber.split('-')[0] &&
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
<div class="grid gap-8 mt-10">
|
||||
<div v-for="(review, index) in reviews.data">
|
||||
<div class="flex items-center">
|
||||
<div class="flex">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
@@ -46,11 +46,11 @@
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="review.review" class="mt-4 leading-5 text-ink-gray-7">
|
||||
{{ review.review }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="review.review" class="mt-4 leading-5 text-ink-gray-7">
|
||||
{{ review.review }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,7 +80,7 @@ const props = defineProps({
|
||||
required: true,
|
||||
},
|
||||
membership: {
|
||||
type: Object,
|
||||
type: Object || null,
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -93,11 +93,19 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
|
||||
import {
|
||||
call,
|
||||
createResource,
|
||||
TextEditor,
|
||||
Button,
|
||||
Dropdown,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { timeAgo } from '@/utils'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
||||
import { ref, inject, onMounted, onUnmounted } from 'vue'
|
||||
import { useTelemetry } from 'frappe-ui/frappe'
|
||||
|
||||
const showTopics = defineModel('showTopics')
|
||||
const newReply = ref('')
|
||||
@@ -107,6 +115,7 @@ const allUsers = inject('$allUsers')
|
||||
const mentionUsers = ref([])
|
||||
const renderEditor = ref(false)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const { capture } = useTelemetry()
|
||||
|
||||
const props = defineProps({
|
||||
topic: {
|
||||
@@ -143,19 +152,6 @@ const replies = createResource({
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const newReplyResource = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'Discussion Reply',
|
||||
reply: newReply.value,
|
||||
topic: props.topic.name,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const fetchMentionUsers = () => {
|
||||
if (user.data?.is_student) {
|
||||
renderEditor.value = true
|
||||
@@ -178,78 +174,61 @@ const fetchMentionUsers = () => {
|
||||
}
|
||||
|
||||
const postReply = () => {
|
||||
newReplyResource.submit(
|
||||
{},
|
||||
{
|
||||
validate() {
|
||||
if (!newReply.value) {
|
||||
return 'Reply cannot be empty'
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
newReply.value = ''
|
||||
replies.reload()
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const editReplyResource = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
return {
|
||||
if (!newReply.value) {
|
||||
toast.error(__('Reply cannot be empty.'))
|
||||
return
|
||||
}
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'Discussion Reply',
|
||||
name: values.name,
|
||||
fieldname: 'reply',
|
||||
value: values.reply,
|
||||
}
|
||||
},
|
||||
})
|
||||
reply: newReply.value,
|
||||
topic: props.topic.name,
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
newReply.value = ''
|
||||
replies.reload()
|
||||
capture('discussion_reply_created')
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
const postEdited = (reply) => {
|
||||
editReplyResource.submit(
|
||||
{
|
||||
name: reply.name,
|
||||
reply: reply.reply,
|
||||
},
|
||||
{
|
||||
validate() {
|
||||
if (!reply.reply) {
|
||||
return 'Reply cannot be empty'
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
reply.editable = false
|
||||
replies.reload()
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!reply.reply) {
|
||||
toast.error(__('Reply cannot be empty.'))
|
||||
return
|
||||
}
|
||||
call('frappe.client.set_value', {
|
||||
doctype: 'Discussion Reply',
|
||||
name: reply.name,
|
||||
fieldname: 'reply',
|
||||
value: reply.reply,
|
||||
})
|
||||
.then(() => {
|
||||
reply.editable = false
|
||||
replies.reload()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
const deleteReplyResource = createResource({
|
||||
url: 'frappe.client.delete',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'Discussion Reply',
|
||||
name: values.name,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const deleteReply = (reply) => {
|
||||
deleteReplyResource.submit(
|
||||
{
|
||||
name: reply.name,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
replies.reload()
|
||||
},
|
||||
}
|
||||
)
|
||||
call('frappe.client.delete', {
|
||||
doctype: 'Discussion Reply',
|
||||
name: reply.name,
|
||||
})
|
||||
.then(() => {
|
||||
replies.reload()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -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!'
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center text-sm font-medium space-x-2">
|
||||
<span>
|
||||
{{ __('What does include in preview mean?') }}
|
||||
{{ __('What are Instructor Notes?') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
|
||||
{{
|
||||
__(
|
||||
'If Include in Preview is enabled for a lesson then the lesson will also be accessible to non logged in users.'
|
||||
'Instructor Notes are private notes that only instructors can see. They can be used to provide additional context or guidance for the lesson.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="hasPermission() && !props.zoomAccount"
|
||||
class="flex items-center space-x-2 mb-5 bg-surface-amber-1 py-1 px-2 rounded-md text-ink-amber-3 text-xs"
|
||||
>
|
||||
<AlertCircle class="size-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Please add a zoom account to the batch to create live classes.') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Live Class') }}
|
||||
</div>
|
||||
<Button v-if="canCreateClass()" @click="openLiveClassModal">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Add') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="liveClasses.data?.length"
|
||||
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 mt-5"
|
||||
>
|
||||
<div
|
||||
v-for="cls in liveClasses.data"
|
||||
class="flex flex-col border rounded-md h-full text-ink-gray-7 hover:border-outline-gray-3 p-3"
|
||||
:class="{
|
||||
'cursor-pointer': hasPermission() && cls.attendees > 0,
|
||||
}"
|
||||
@click="
|
||||
() => {
|
||||
openAttendanceModal(cls)
|
||||
}
|
||||
"
|
||||
>
|
||||
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
|
||||
{{ cls.title }}
|
||||
</div>
|
||||
<div class="short-introduction">
|
||||
{{ cls.description }}
|
||||
</div>
|
||||
<div class="mt-auto space-y-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Clock class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ dayjs(getClassStart(cls)).format('hh:mm A') }} -
|
||||
{{ dayjs(getClassEnd(cls)).format('hh:mm A') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="canAccessClass(cls)"
|
||||
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
|
||||
>
|
||||
<a
|
||||
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
||||
:href="cls.start_url"
|
||||
target="_blank"
|
||||
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||
:class="cls.join_url ? 'w-full' : 'w-1/2'"
|
||||
>
|
||||
<Monitor class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Start') }}
|
||||
</a>
|
||||
<a
|
||||
:href="cls.join_url"
|
||||
target="_blank"
|
||||
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||
>
|
||||
<Video class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Join') }}
|
||||
</a>
|
||||
</div>
|
||||
<Tooltip
|
||||
v-else-if="hasClassEnded(cls)"
|
||||
:text="__('This class has ended')"
|
||||
placement="right"
|
||||
>
|
||||
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
|
||||
<Info class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Ended') }}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-ink-gray-5 mt-2">
|
||||
{{ __('No live classes scheduled') }}
|
||||
</div>
|
||||
|
||||
<LiveClassModal
|
||||
:batch="props.batch"
|
||||
:zoomAccount="props.zoomAccount"
|
||||
v-model="showLiveClassModal"
|
||||
v-model:reloadLiveClasses="liveClasses"
|
||||
/>
|
||||
|
||||
<LiveClassAttendance
|
||||
v-if="showAttendance"
|
||||
v-model="showAttendance"
|
||||
:live_class="attendanceFor"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createListResource, Button, Tooltip } from 'frappe-ui'
|
||||
import {
|
||||
Plus,
|
||||
Clock,
|
||||
Calendar,
|
||||
Video,
|
||||
Monitor,
|
||||
Info,
|
||||
AlertCircle,
|
||||
} from 'lucide-vue-next'
|
||||
import { inject, ref } from 'vue'
|
||||
import { formatTime } from '@/utils/'
|
||||
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
|
||||
import LiveClassAttendance from '@/components/Modals/LiveClassAttendance.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const showLiveClassModal = ref(false)
|
||||
const dayjs = inject('$dayjs')
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const showAttendance = ref(false)
|
||||
const attendanceFor = ref(null)
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
zoomAccount: String,
|
||||
})
|
||||
|
||||
const liveClasses = createListResource({
|
||||
doctype: 'LMS Live Class',
|
||||
filters: {
|
||||
batch_name: props.batch,
|
||||
},
|
||||
fields: [
|
||||
'title',
|
||||
'description',
|
||||
'time',
|
||||
'date',
|
||||
'duration',
|
||||
'attendees',
|
||||
'start_url',
|
||||
'join_url',
|
||||
'owner',
|
||||
],
|
||||
orderBy: 'date',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const openLiveClassModal = () => {
|
||||
showLiveClassModal.value = true
|
||||
}
|
||||
|
||||
const canCreateClass = () => {
|
||||
if (readOnlyMode) return false
|
||||
if (!props.zoomAccount) return false
|
||||
return hasPermission()
|
||||
}
|
||||
|
||||
const hasPermission = () => {
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
}
|
||||
|
||||
const canAccessClass = (cls) => {
|
||||
if (cls.date < dayjs().format('YYYY-MM-DD')) return false
|
||||
if (cls.date > dayjs().format('YYYY-MM-DD')) return false
|
||||
if (hasClassEnded(cls)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const getClassStart = (cls) => {
|
||||
return new Date(`${cls.date}T${cls.time}`)
|
||||
}
|
||||
|
||||
const getClassEnd = (cls) => {
|
||||
const classStart = getClassStart(cls)
|
||||
return new Date(classStart.getTime() + cls.duration * 60000)
|
||||
}
|
||||
|
||||
const hasClassEnded = (cls) => {
|
||||
const classEnd = getClassEnd(cls)
|
||||
const now = new Date()
|
||||
return now > classEnd
|
||||
}
|
||||
|
||||
const openAttendanceModal = (cls) => {
|
||||
if (!hasPermission()) return
|
||||
if (cls.attendees <= 0) return
|
||||
showAttendance.value = true
|
||||
attendanceFor.value = cls
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.short-introduction {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0.25rem 0 1.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Add Existing User as Evaluator'),
|
||||
size: 'md',
|
||||
actions: [
|
||||
{
|
||||
label: __('Add'),
|
||||
variant: 'solid',
|
||||
loading: submitting,
|
||||
onClick: ({ close }: any) => addEvaluator(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<Link doctype="User" v-model="selectedUser" :label="__('Select User')" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { call, Dialog, toast } from 'frappe-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { cleanError } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = defineModel<boolean>({ default: false })
|
||||
const selectedUser = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
const emit = defineEmits<{
|
||||
added: []
|
||||
}>()
|
||||
|
||||
watch(show, (isOpen) => {
|
||||
if (isOpen) {
|
||||
selectedUser.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
const addEvaluator = async (close?: () => void) => {
|
||||
if (!selectedUser.value?.trim()) {
|
||||
toast.error(__('Please select a user'))
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await call('lms.lms.api.save_role', {
|
||||
user: selectedUser.value,
|
||||
role: 'Batch Evaluator',
|
||||
value: 1,
|
||||
})
|
||||
toast.success(__('Evaluator added successfully'))
|
||||
emit('added')
|
||||
close?.()
|
||||
} catch (err: any) {
|
||||
toast.error(cleanError(err.messages?.[0]) || __('Unable to add evaluator'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -20,11 +20,15 @@
|
||||
:options="assessmentTypes"
|
||||
v-model="assessmentType"
|
||||
:label="__('Type')"
|
||||
placeholder=" "
|
||||
@update:modelValue="() => (assessment = null)"
|
||||
/>
|
||||
<Link
|
||||
v-if="assessmentType"
|
||||
v-model="assessment"
|
||||
:doctype="assessmentType"
|
||||
:label="__('Assessment')"
|
||||
placeholder=" "
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
close()
|
||||
@@ -49,9 +53,9 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, FormControl, createResource, toast } from 'frappe-ui'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = defineModel()
|
||||
const assessmentType = ref(null)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
: __('Edit Assignment')
|
||||
}}
|
||||
</div>
|
||||
<div class="space-y-4 max-h-[75vh] overflow-y-auto">
|
||||
<div class="space-y-4 max-h-[75vh] overflow-y-auto p-1">
|
||||
<FormControl
|
||||
v-model="assignment.title"
|
||||
:label="__('Title')"
|
||||
@@ -43,7 +43,7 @@
|
||||
@change="(val) => (assignment.question = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[10rem] max-h-[18rem] overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,7 +73,7 @@
|
||||
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { escapeHTML, sanitizeHTML } from '@/utils'
|
||||
import { Link } from 'frappe-ui/frappe'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = defineModel()
|
||||
const assignments = defineModel<Assignments>('assignments')
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Add a course'),
|
||||
size: 'sm',
|
||||
title: __('Add a course to the batch'),
|
||||
size: 'lg',
|
||||
actions: [
|
||||
{
|
||||
label: __('Submit'),
|
||||
@@ -19,6 +19,7 @@
|
||||
v-model="course"
|
||||
:label="__('Course')"
|
||||
:required="true"
|
||||
:filters="{ published: 1 }"
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
close()
|
||||
@@ -40,7 +41,7 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, createResource, toast } from 'frappe-ui'
|
||||
import { Dialog, toast } from 'frappe-ui'
|
||||
import { ref, inject } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
@@ -62,37 +63,28 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const createBatchCourse = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'Batch Course',
|
||||
parent: props.batch,
|
||||
parenttype: 'LMS Batch',
|
||||
parentfield: 'courses',
|
||||
course: course.value,
|
||||
evaluator: evaluator.value,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const addCourse = (close) => {
|
||||
createBatchCourse.submit(
|
||||
{},
|
||||
courses.value.insert.submit(
|
||||
{
|
||||
course: course.value,
|
||||
evaluator: evaluator.value,
|
||||
parent: props.batch,
|
||||
parenttype: 'LMS Batch',
|
||||
parentfield: 'courses',
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
if (user.data?.is_system_manager)
|
||||
updateOnboardingStep('add_batch_course')
|
||||
|
||||
close()
|
||||
courses.value.reload()
|
||||
course.value = null
|
||||
evaluator.value = null
|
||||
toast.success(__('Course added to batch successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.log(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: 'xl',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div class="p-5 space-y-10 text-base">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Avatar :image="student.user_image" size="3xl" />
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ student.full_name }}
|
||||
</div>
|
||||
<Badge
|
||||
v-if="
|
||||
Object.keys(student.assessments).length ||
|
||||
Object.keys(student.courses).length
|
||||
"
|
||||
:theme="student.progress === 100 ? 'green' : 'red'"
|
||||
>
|
||||
{{ student.progress }}% {{ __('Complete') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-7">
|
||||
{{ student.email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Assessments -->
|
||||
<div
|
||||
v-if="Object.keys(student.assessments).length"
|
||||
class="space-y-2 text-sm"
|
||||
>
|
||||
<div
|
||||
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
|
||||
>
|
||||
<span class="flex-1">
|
||||
{{ __('Assessment') }}
|
||||
</span>
|
||||
<span>
|
||||
{{ __('Percentage/Status') }}
|
||||
</span>
|
||||
</div>
|
||||
<router-link
|
||||
v-for="assessment in Object.keys(student.assessments)"
|
||||
class="flex items-center text-ink-gray-7 font-medium"
|
||||
:to="{
|
||||
name:
|
||||
student.assessments[assessment].type == 'LMS Assignment'
|
||||
? 'AssignmentSubmission'
|
||||
: '',
|
||||
params:
|
||||
student.assessments[assessment].type == 'LMS Assignment'
|
||||
? {
|
||||
assignmentID:
|
||||
student.assessments[assessment].assessment,
|
||||
submissionName:
|
||||
student.assessments[assessment].submission,
|
||||
}
|
||||
: {},
|
||||
}"
|
||||
>
|
||||
<span class="flex-1">
|
||||
{{ assessment }}
|
||||
</span>
|
||||
<span v-if="isAssignment(student.assessments[assessment].status)">
|
||||
<Badge
|
||||
:theme="
|
||||
getStatusTheme(student.assessments[assessment].status)
|
||||
"
|
||||
>
|
||||
{{ student.assessments[assessment].status }}
|
||||
</Badge>
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ student.assessments[assessment].status }}
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Courses -->
|
||||
<div
|
||||
v-if="Object.keys(student.courses).length"
|
||||
class="space-y-2 text-sm"
|
||||
>
|
||||
<div
|
||||
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
|
||||
>
|
||||
<span class="flex-1">
|
||||
{{ __('Courses') }}
|
||||
</span>
|
||||
<span>
|
||||
{{ __('Progress') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="course in Object.keys(student.courses)"
|
||||
class="flex items-center text-ink-gray-7 font-medium"
|
||||
>
|
||||
<span class="flex-1">
|
||||
{{ course }}
|
||||
</span>
|
||||
<span>
|
||||
{{ Math.floor(student.courses[course]) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Heatmap -->
|
||||
<StudentHeatmap :member="student.email" :days="120" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Avatar, Badge, Dialog } from 'frappe-ui'
|
||||
import StudentHeatmap from '@/components/StudentHeatmap.vue'
|
||||
|
||||
const show = defineModel()
|
||||
const props = defineProps({
|
||||
student: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const isAssignment = (value) => {
|
||||
return isNaN(value)
|
||||
}
|
||||
|
||||
const getStatusTheme = (status) => {
|
||||
if (status === 'Pass') {
|
||||
return 'green'
|
||||
} else if (status == 'Not Graded') {
|
||||
return 'orange'
|
||||
} else {
|
||||
return 'red'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -16,7 +16,12 @@
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="space-y-4 text-base">
|
||||
<FormControl label="Title" v-model="chapter.title" :required="true" />
|
||||
<FormControl
|
||||
label="Title"
|
||||
v-model="chapter.title"
|
||||
:required="true"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('SCORM Package')"
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
@change="(val) => (topic.reply = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,17 +34,13 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Dialog,
|
||||
FormControl,
|
||||
TextEditor,
|
||||
createResource,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { call, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||
import { reactive } from 'vue'
|
||||
import { singularize } from '@/utils'
|
||||
import { useTelemetry } from 'frappe-ui/frappe'
|
||||
|
||||
const topics = defineModel('reloadTopics')
|
||||
const { capture } = useTelemetry()
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
@@ -66,64 +62,50 @@ const topic = reactive({
|
||||
reply: '',
|
||||
})
|
||||
|
||||
const topicResource = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'Discussion Topic',
|
||||
reference_doctype: props.doctype,
|
||||
reference_docname: props.docname,
|
||||
title: topic.title,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const replyResource = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'Discussion Reply',
|
||||
topic: values.topic,
|
||||
reply: topic.reply,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const submitTopic = (close) => {
|
||||
topicResource.submit(
|
||||
{},
|
||||
{
|
||||
validate() {
|
||||
if (!topic.title) {
|
||||
return 'Title cannot be empty.'
|
||||
}
|
||||
if (!topic.reply) {
|
||||
return 'Reply cannot be empty.'
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
replyResource.submit(
|
||||
{
|
||||
topic: data.name,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
topic.title = ''
|
||||
topic.reply = ''
|
||||
topics.value.reload()
|
||||
close()
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!topic.title) {
|
||||
toast.error(__('Title cannot be empty.'))
|
||||
return
|
||||
}
|
||||
if (!topic.reply) {
|
||||
toast.error(__('Details cannot be empty.'))
|
||||
return
|
||||
}
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'Discussion Topic',
|
||||
reference_doctype: props.doctype,
|
||||
reference_docname: props.docname,
|
||||
title: topic.title,
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
createReply(data.name, close)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
const createReply = (topicName, close) => {
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'Discussion Reply',
|
||||
topic: topicName,
|
||||
reply: topic.reply,
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
topic.title = ''
|
||||
topic.reply = ''
|
||||
topics.value.reload()
|
||||
capture('discussion_topic_created')
|
||||
close()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -37,10 +37,12 @@
|
||||
<FormControl
|
||||
v-model="profile.first_name"
|
||||
:label="__('First Name')"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="profile.last_name"
|
||||
:label="__('Last Name')"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl v-model="profile.headline" :label="__('Headline')" />
|
||||
|
||||
@@ -141,7 +143,25 @@ const updateProfile = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const validateMandatoryFields = () => {
|
||||
let missingFields = []
|
||||
if (!profile.first_name) missingFields.push(__('First Name'))
|
||||
if (!profile.last_name) missingFields.push(__('Last Name'))
|
||||
if (!profile.image) missingFields.push(__('Profile Image'))
|
||||
if (missingFields.length) {
|
||||
toast.error(
|
||||
__('Please fill the mandatory fields: {0}').format(
|
||||
missingFields.join(', ')
|
||||
)
|
||||
)
|
||||
console.error('Missing mandatory fields:', missingFields)
|
||||
}
|
||||
return missingFields.length
|
||||
}
|
||||
|
||||
const saveProfile = () => {
|
||||
let missingMandatoryFields = validateMandatoryFields()
|
||||
if (missingMandatoryFields) return
|
||||
profile.bio = sanitizeHTML(profile.bio)
|
||||
updateProfile.submit(
|
||||
{},
|
||||
|
||||
@@ -34,10 +34,11 @@
|
||||
:required="true"
|
||||
:placeholder="__('Your enrollment in {{ batch_name }} is confirmed')"
|
||||
/>
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
:description="__('Use HTML content for the email response')"
|
||||
:label="__('Use HTML')"
|
||||
v-model="template.use_html"
|
||||
type="checkbox"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="template.use_html"
|
||||
@@ -67,7 +68,7 @@
|
||||
'Dear {{ member_name }},\n\nYou have been enrolled in our upcoming batch {{ batch_name }}.\n\nThanks,\nFrappe Learning'
|
||||
)
|
||||
"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,7 +76,7 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { call, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||
import { call, Dialog, FormControl, TextEditor, toast, Switch } from 'frappe-ui'
|
||||
import { reactive, watch } from 'vue'
|
||||
import { cleanError } from '@/utils'
|
||||
|
||||
@@ -88,6 +89,7 @@ const props = defineProps({
|
||||
|
||||
const show = defineModel()
|
||||
const emailTemplates = defineModel('emailTemplates')
|
||||
const emit = defineEmits(['created'])
|
||||
const template = reactive({
|
||||
name: '',
|
||||
subject: '',
|
||||
@@ -113,6 +115,7 @@ const createNewTemplate = (close) => {
|
||||
{
|
||||
onSuccess() {
|
||||
emailTemplates.value.reload()
|
||||
emit('created', template.name)
|
||||
refreshForm(close)
|
||||
toast.success(__('Email Template created successfully'))
|
||||
},
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!evaluation.course" class="text-ink-gray-7">
|
||||
{{ __('Please select a course to view available slots.') }}
|
||||
</div>
|
||||
<div v-else class="text-ink-red-3">
|
||||
{{ __('No slots available for the selected course.') }}
|
||||
</div>
|
||||
|
||||
@@ -122,10 +122,13 @@
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="flex flex-col space-y-4 p-5">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="certificate.published"
|
||||
:label="__('Published')"
|
||||
:description="
|
||||
__('Make this certificate visible to the participant.')
|
||||
"
|
||||
:disabled="!userIsEvaluator()"
|
||||
/>
|
||||
<Link
|
||||
@@ -169,6 +172,7 @@ import {
|
||||
Button,
|
||||
FormControl,
|
||||
createResource,
|
||||
Switch,
|
||||
Tabs,
|
||||
Tooltip,
|
||||
Textarea,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
>
|
||||
<template #body>
|
||||
<div class="p-5 min-h-[300px]">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Training Feedback') }}
|
||||
</div>
|
||||
<ListView
|
||||
|
||||
@@ -29,14 +29,12 @@
|
||||
:label="__('Date')"
|
||||
:required="true"
|
||||
/>
|
||||
<Tooltip :text="__('Duration of the live class in minutes')">
|
||||
<FormControl
|
||||
type="number"
|
||||
v-model="liveClass.duration"
|
||||
:label="__('Duration')"
|
||||
:required="true"
|
||||
/>
|
||||
</Tooltip>
|
||||
<FormControl
|
||||
type="number"
|
||||
v-model="liveClass.duration"
|
||||
:label="__('Duration (in minutes)')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<Tooltip
|
||||
@@ -67,6 +65,7 @@
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
v-if="props.conferencingProvider === 'Zoom'"
|
||||
v-model="liveClass.auto_recording"
|
||||
type="select"
|
||||
:options="getRecordingOptions()"
|
||||
@@ -84,16 +83,10 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Dialog,
|
||||
createResource,
|
||||
Tooltip,
|
||||
FormControl,
|
||||
Autocomplete,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { Dialog, createResource, Tooltip, FormControl, toast } from 'frappe-ui'
|
||||
import { reactive, inject, onMounted } from 'vue'
|
||||
import { getTimezones, getUserTimezone } from '@/utils/'
|
||||
import Autocomplete from '@/components/Controls/Autocomplete.vue'
|
||||
|
||||
const liveClasses = defineModel('reloadLiveClasses')
|
||||
const show = defineModel()
|
||||
@@ -105,10 +98,9 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
zoomAccount: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
zoomAccount: String,
|
||||
googleMeetAccount: String,
|
||||
conferencingProvider: String,
|
||||
})
|
||||
|
||||
let liveClass = reactive({
|
||||
@@ -165,8 +157,23 @@ const createLiveClass = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const createGoogleMeetLiveClass = createResource({
|
||||
url: 'lms.lms.doctype.lms_batch.lms_batch.create_google_meet_live_class',
|
||||
makeParams(values) {
|
||||
return {
|
||||
batch_name: values.batch,
|
||||
google_meet_account: props.googleMeetAccount,
|
||||
...values,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const submitLiveClass = (close) => {
|
||||
return createLiveClass.submit(liveClass, {
|
||||
const resource =
|
||||
props.conferencingProvider === 'Google Meet'
|
||||
? createGoogleMeetLiveClass
|
||||
: createLiveClass
|
||||
return resource.submit(liveClass, {
|
||||
validate() {
|
||||
validateFormFields()
|
||||
},
|
||||
@@ -177,6 +184,7 @@ const submitLiveClass = (close) => {
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Add New Member'),
|
||||
size: 'lg',
|
||||
actions: [
|
||||
{
|
||||
label: __('Add'),
|
||||
variant: 'solid',
|
||||
loading: submitting,
|
||||
onClick: ({ close }: any) => addMember(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
v-model="member.email"
|
||||
:label="__('Email')"
|
||||
placeholder="jane@doe.com"
|
||||
type="email"
|
||||
:required="true"
|
||||
@keyup.enter="addMember()"
|
||||
/>
|
||||
<div class="flex items-center gap-3">
|
||||
<FormControl
|
||||
v-model="member.first_name"
|
||||
:label="__('First Name')"
|
||||
placeholder="Jane"
|
||||
type="text"
|
||||
class="w-full"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="member.last_name"
|
||||
:label="__('Last Name')"
|
||||
placeholder="Doe"
|
||||
type="text"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ __('Roles') }}
|
||||
</div>
|
||||
<div class="grid md:grid-cols-2 gap-x-6 gap-y-3">
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Student')"
|
||||
v-model="roles.lms_student"
|
||||
/>
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Course Creator')"
|
||||
v-model="roles.course_creator"
|
||||
/>
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Evaluator')"
|
||||
v-model="roles.batch_evaluator"
|
||||
/>
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Moderator')"
|
||||
v-model="roles.moderator"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { call, Dialog, FormControl, toast, Switch } from 'frappe-ui'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { cleanError } from '@/utils'
|
||||
|
||||
const show = defineModel<boolean>({ default: false })
|
||||
const submitting = ref(false)
|
||||
|
||||
const props = defineProps<{
|
||||
defaultRoles?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: [user: any]
|
||||
}>()
|
||||
|
||||
const ROLE_MAP: Record<string, string> = {
|
||||
moderator: 'Moderator',
|
||||
course_creator: 'Course Creator',
|
||||
batch_evaluator: 'Batch Evaluator',
|
||||
lms_student: 'LMS Student',
|
||||
}
|
||||
|
||||
const member = reactive({
|
||||
email: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
})
|
||||
|
||||
const roles = reactive({
|
||||
moderator: false,
|
||||
course_creator: false,
|
||||
batch_evaluator: false,
|
||||
lms_student: false,
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
member.email = ''
|
||||
member.first_name = ''
|
||||
member.last_name = ''
|
||||
applyDefaultRoles()
|
||||
}
|
||||
|
||||
const applyDefaultRoles = () => {
|
||||
roles.moderator = props.defaultRoles?.includes('moderator') ?? false
|
||||
roles.course_creator = props.defaultRoles?.includes('course_creator') ?? false
|
||||
roles.batch_evaluator =
|
||||
props.defaultRoles?.includes('batch_evaluator') ?? false
|
||||
roles.lms_student = props.defaultRoles?.includes('lms_student') ?? false
|
||||
}
|
||||
|
||||
watch(show, (isOpen) => {
|
||||
if (isOpen) {
|
||||
resetForm()
|
||||
}
|
||||
})
|
||||
|
||||
const assignRoles = async (userEmail: string) => {
|
||||
const selectedRoles = Object.entries(roles).filter(([_, checked]) => checked)
|
||||
|
||||
for (const [key, _] of selectedRoles) {
|
||||
await call('lms.lms.api.save_role', {
|
||||
user: userEmail,
|
||||
role: ROLE_MAP[key],
|
||||
value: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const addMember = async (close?: () => void) => {
|
||||
if (!member.email?.trim()) {
|
||||
toast.error(__('Email is required'))
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const user = await call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'User',
|
||||
email: member.email.trim(),
|
||||
first_name: member.first_name.trim() || undefined,
|
||||
last_name: member.last_name.trim() || undefined,
|
||||
},
|
||||
})
|
||||
|
||||
await assignRoles(user.name)
|
||||
|
||||
toast.success(__('Member added successfully'))
|
||||
emit('created', user)
|
||||
resetForm()
|
||||
close?.()
|
||||
} catch (err: any) {
|
||||
toast.error(cleanError(err.messages?.[0]) || __('Unable to add member'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: '5xl',
|
||||
size: '3xl',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
@@ -10,17 +10,14 @@
|
||||
<div class="text-lg font-semibold text-ink-gray-9 mb-5">
|
||||
{{ __(props.title) }}
|
||||
</div>
|
||||
<div
|
||||
<Switch
|
||||
v-if="!editMode"
|
||||
class="flex items-center text-xs text-ink-gray-7 space-x-5"
|
||||
>
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Choose an existing question')"
|
||||
v-model="chooseFromExisting"
|
||||
class="!p-0"
|
||||
/>
|
||||
</div>
|
||||
size="sm"
|
||||
:label="__('Choose an existing question')"
|
||||
:description="__('Select from questions you have already created')"
|
||||
v-model="chooseFromExisting"
|
||||
class="!p-0"
|
||||
/>
|
||||
<div v-if="!chooseFromExisting || editMode">
|
||||
<div>
|
||||
<label class="block text-xs text-ink-gray-5 mb-1">
|
||||
@@ -31,7 +28,7 @@
|
||||
@change="(val) => (question.question = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-8 mt-4">
|
||||
@@ -75,10 +72,11 @@
|
||||
:label="__('Explanation')"
|
||||
v-model="question[`explanation_${n}`]"
|
||||
/>
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Correct Answer')"
|
||||
:description="__('Mark this option as a correct answer.')"
|
||||
v-model="question[`is_correct_${n}`]"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,7 +162,7 @@ populateFields()
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: __('Add a new question'),
|
||||
default: __('Add new question'),
|
||||
},
|
||||
questionDetail: {
|
||||
type: [Object, null],
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Add a Student'),
|
||||
size: 'sm',
|
||||
title: __('Enroll a Student'),
|
||||
size: 'lg',
|
||||
actions: [
|
||||
{
|
||||
label: 'Submit',
|
||||
@@ -18,10 +18,24 @@
|
||||
<Link
|
||||
doctype="User"
|
||||
v-model="student"
|
||||
:filters="{ ignore_user_type: 1 }"
|
||||
placeholder=" "
|
||||
:label="__('Student')"
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
openSettings('Members', close)
|
||||
() => {
|
||||
openSettings('Members')
|
||||
show = false
|
||||
}
|
||||
"
|
||||
:required="true"
|
||||
/>
|
||||
<Link
|
||||
doctype="LMS Payment"
|
||||
v-model="payment"
|
||||
placeholder=" "
|
||||
:label="__('Payment')"
|
||||
:onCreate="
|
||||
() => {
|
||||
openSettings('Transactions')
|
||||
show = false
|
||||
}
|
||||
"
|
||||
@@ -31,54 +45,49 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, createResource, toast } from 'frappe-ui'
|
||||
import { call, Dialog, toast } from 'frappe-ui'
|
||||
import { ref, inject } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import { openSettings } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const students = defineModel('reloadStudents')
|
||||
const batchModal = defineModel('batchModal')
|
||||
const student = ref()
|
||||
const student = ref(null)
|
||||
const payment = ref(null)
|
||||
const user = inject('$user')
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
const show = defineModel()
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
students: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const studentResource = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Batch Enrollment',
|
||||
batch: props.batch,
|
||||
member: student.value,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const addStudent = (close) => {
|
||||
studentResource.submit(
|
||||
{},
|
||||
props.students.insert.submit(
|
||||
{
|
||||
member: student.value,
|
||||
payment: payment.value,
|
||||
batch: props.batch.data?.name,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
if (user.data?.is_system_manager)
|
||||
updateOnboardingStep('add_batch_student')
|
||||
|
||||
students.value.reload()
|
||||
batchModal.value.reload()
|
||||
student.value = null
|
||||
payment.value = null
|
||||
props.batch.reload()
|
||||
close()
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: '4xl',
|
||||
title: __('Video Statistics for {0}').format(lessonTitle),
|
||||
title: __('Video Statistics'),
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
@@ -21,17 +21,22 @@
|
||||
class="mt-2 mr-5 w-[25%]"
|
||||
/> -->
|
||||
</div>
|
||||
<div v-if="currentTab" class="mt-4">
|
||||
<div
|
||||
v-if="currentTab"
|
||||
:class="{
|
||||
'mt-5': tabs.length > 1,
|
||||
}"
|
||||
>
|
||||
<div class="grid grid-cols-[55%,40%] gap-5">
|
||||
<div
|
||||
class="space-y-5 border rounded-md p-2 pt-4 h-[70vh] overflow-y-auto"
|
||||
class="space-y-5 border rounded-md p-2 pt-4 max-h-[70vh] overflow-y-auto"
|
||||
>
|
||||
<div class="grid grid-cols-[70%,30%] text-sm text-ink-gray-5">
|
||||
<div class="px-4">
|
||||
{{ __('Member') }}
|
||||
</div>
|
||||
<div class="text-center">
|
||||
{{ __('Watch Time') }}
|
||||
{{ __('Watch Time (mins)') }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -68,15 +73,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: __('Average Watch Time'),
|
||||
value: averageWatchTime,
|
||||
}"
|
||||
<NumberChartGraph
|
||||
:title="__('Average Watch Time (mins)')"
|
||||
:value="averageWatchTime"
|
||||
/>
|
||||
<div v-if="isPlyrSource">
|
||||
<div class="video-player" :src="currentTab"></div>
|
||||
<div
|
||||
class="video-player"
|
||||
:data-plyr-provider="provider"
|
||||
:src="currentTab"
|
||||
></div>
|
||||
</div>
|
||||
<VideoBlock v-else :file="currentTab" />
|
||||
</div>
|
||||
@@ -101,6 +107,7 @@ import {
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { enablePlyr, formatTimestamp } from '@/utils'
|
||||
import VideoBlock from '@/components/VideoBlock.vue'
|
||||
import NumberChartGraph from '@/components/NumberChartGraph.vue'
|
||||
|
||||
const show = defineModel<boolean | undefined>()
|
||||
const currentTab = ref<string>('')
|
||||
@@ -171,7 +178,7 @@ watch(show, () => {
|
||||
|
||||
const statisticsData = computed(() => {
|
||||
const grouped = <Record<string, any[]>>{}
|
||||
statistics.data.forEach((item: { source: string }) => {
|
||||
statistics.data?.forEach((item: { source: string }) => {
|
||||
if (!grouped[item.source]) {
|
||||
grouped[item.source] = []
|
||||
}
|
||||
|
||||
@@ -18,10 +18,13 @@
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="mb-4">
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="account.enabled"
|
||||
:label="__('Enabled')"
|
||||
type="checkbox"
|
||||
:description="
|
||||
__('Activate this Zoom account for scheduling meetings.')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
@@ -61,7 +64,7 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { call, Dialog, FormControl, toast } from 'frappe-ui'
|
||||
import { call, Dialog, FormControl, Switch, toast } from 'frappe-ui'
|
||||
import { inject, reactive, watch } from 'vue'
|
||||
import { User } from '@/components/Settings/types'
|
||||
import { openSettings, cleanError } from '@/utils'
|
||||
@@ -109,16 +112,14 @@ const account = reactive({
|
||||
client_secret: '',
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
accountID: {
|
||||
type: String,
|
||||
default: 'new',
|
||||
},
|
||||
})
|
||||
const props = defineProps<{
|
||||
accountID: string | null
|
||||
}>()
|
||||
|
||||
watch(
|
||||
() => props.accountID,
|
||||
(val) => {
|
||||
console.log(props.accountID)
|
||||
if (val === 'new') {
|
||||
account.name = ''
|
||||
account.enabled = false
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<slot name="prefix" />
|
||||
<div class="font-semibold text-2xl">
|
||||
<div class="font-semibold text-ink-gray-9 text-2xl">
|
||||
{{ value }}
|
||||
</div>
|
||||
<slot name="suffix" />
|
||||
|
||||
@@ -1,56 +1,72 @@
|
||||
<template>
|
||||
<div v-if="quiz.data">
|
||||
<div
|
||||
class="bg-surface-blue-2 space-y-2 py-2 px-3 mb-4 rounded-md text-sm text-ink-blue-2 leading-5"
|
||||
class="bg-surface-blue-2 text-ink-blue-3 space-y-2 p-3 mb-4 rounded-lg leading-5"
|
||||
>
|
||||
<div v-if="inVideo">
|
||||
{{ __('You will have to complete the quiz to continue the video') }}
|
||||
</div>
|
||||
<div class="leading-5">
|
||||
{{
|
||||
__('This quiz consists of {0} questions.').format(questions.length)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data?.duration" class="leading-5">
|
||||
<div class="font-medium">
|
||||
{{
|
||||
__(
|
||||
'Please ensure that you complete all the questions in {0} minutes.'
|
||||
).format(quiz.data.duration)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data?.duration" class="leading-5">
|
||||
{{
|
||||
__(
|
||||
'If you fail to do so, the quiz will be automatically submitted when the timer ends.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data.passing_percentage" class="leading-relaxed">
|
||||
{{
|
||||
__(
|
||||
'You will have to get {0}% correct answers in order to pass the quiz.'
|
||||
).format(quiz.data.passing_percentage)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data.max_attempts" class="leading-5">
|
||||
{{
|
||||
__('You can attempt this quiz {0}.').format(
|
||||
quiz.data.max_attempts == 1
|
||||
? '1 time'
|
||||
: `${quiz.data.max_attempts} times`
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="quiz.data.enable_negative_marking" class="leading-5">
|
||||
{{
|
||||
__(
|
||||
'If you answer incorrectly, {0} {1} will be deducted from your score for each incorrect answer.'
|
||||
).format(
|
||||
quiz.data.marks_to_cut,
|
||||
quiz.data.marks_to_cut == 1 ? 'mark' : 'marks'
|
||||
'Please read the following instructions carefully before starting the quiz'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<ol class="list-decimal list-inside space-y-2">
|
||||
<li v-if="inVideo">
|
||||
{{ __('You will have to complete the quiz to continue the video') }}
|
||||
</li>
|
||||
<li>
|
||||
{{
|
||||
__(
|
||||
'Do not refresh the page or close this window. If you do, the quiz will be submitted automatically.'
|
||||
)
|
||||
}}
|
||||
</li>
|
||||
<li>
|
||||
{{
|
||||
__('This quiz consists of {0} questions.').format(questions.length)
|
||||
}}
|
||||
</li>
|
||||
<li v-if="quiz.data?.duration">
|
||||
{{
|
||||
__(
|
||||
'Please ensure that you complete all the questions in {0} minutes.'
|
||||
).format(quiz.data.duration)
|
||||
}}
|
||||
</li>
|
||||
<li v-if="quiz.data?.duration">
|
||||
{{
|
||||
__(
|
||||
'If you fail to do so, the quiz will be automatically submitted when the timer ends.'
|
||||
)
|
||||
}}
|
||||
</li>
|
||||
<li v-if="quiz.data.passing_percentage">
|
||||
{{
|
||||
__(
|
||||
'You will have to get {0}% correct answers in order to pass the quiz.'
|
||||
).format(quiz.data.passing_percentage)
|
||||
}}
|
||||
</li>
|
||||
<li v-if="quiz.data.max_attempts">
|
||||
{{
|
||||
__('You can attempt this quiz {0}.').format(
|
||||
quiz.data.max_attempts == 1
|
||||
? '1 time'
|
||||
: `${quiz.data.max_attempts} times`
|
||||
)
|
||||
}}
|
||||
</li>
|
||||
<li v-if="quiz.data.enable_negative_marking">
|
||||
{{
|
||||
__(
|
||||
'If you answer incorrectly, {0} {1} will be deducted from your score for each incorrect answer.'
|
||||
).format(
|
||||
quiz.data.marks_to_cut,
|
||||
quiz.data.marks_to_cut == 1 ? 'mark' : 'marks'
|
||||
)
|
||||
}}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div v-if="quiz.data.duration" class="flex flex-col space-x-1 my-4">
|
||||
@@ -104,16 +120,12 @@
|
||||
<div v-for="(question, qtidx) in questions">
|
||||
<div
|
||||
v-if="qtidx == activeQuestion - 1 && questionDetails.data"
|
||||
class="border rounded-md p-5"
|
||||
class="border rounded-lg p-5"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
<span class="mr-2">
|
||||
{{ __('Question {0}').format(activeQuestion) }}:
|
||||
</span>
|
||||
<span>
|
||||
{{ getInstructions(questionDetails.data) }}
|
||||
</span>
|
||||
{{ __('Question {0}').format(activeQuestion) }} -
|
||||
{{ getInstructions(questionDetails.data) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-9 text-sm font-semibold item-left">
|
||||
{{ question.marks }}
|
||||
@@ -135,6 +147,7 @@
|
||||
:name="encodeURIComponent(questionDetails.data.question)"
|
||||
class="w-3.5 h-3.5 text-ink-gray-9 focus:ring-outline-gray-modals"
|
||||
@change="markAnswer(index)"
|
||||
:checked="selectedOptions[index - 1]"
|
||||
/>
|
||||
|
||||
<input
|
||||
@@ -143,6 +156,7 @@
|
||||
:name="encodeURIComponent(questionDetails.data.question)"
|
||||
class="w-3.5 h-3.5 text-ink-gray-9 rounded-sm focus:ring-outline-gray-modals"
|
||||
@change="markAnswer(index)"
|
||||
:checked="selectedOptions[index - 1]"
|
||||
/>
|
||||
<div
|
||||
v-else-if="quiz.data.show_answers"
|
||||
@@ -205,17 +219,64 @@
|
||||
@change="(val) => (possibleAnswer = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
<div class="flex items-center justify-between mt-8">
|
||||
<Checkbox
|
||||
:label="__('Mark for review')"
|
||||
:model-value="reviewQuestions.includes(activeQuestion) ? 1 : 0"
|
||||
@change="markForReview($event, activeQuestion)"
|
||||
/>
|
||||
<!-- <div class="text-sm text-ink-gray-5">
|
||||
{{
|
||||
__('Question {0} of {1}').format(
|
||||
activeQuestion,
|
||||
questions.length
|
||||
)
|
||||
}}
|
||||
</div> -->
|
||||
<div
|
||||
v-if="!quiz.data.show_answers"
|
||||
class="flex items-center space-x-2"
|
||||
>
|
||||
<Button
|
||||
@click="switchQuestion(activeQuestion - 1)"
|
||||
:disabled="activeQuestion == 1"
|
||||
class="rounded-full"
|
||||
>
|
||||
<template #icon>
|
||||
<ChevronLeft class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<span
|
||||
v-for="item in paginationWindow"
|
||||
:key="item"
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center text-sm"
|
||||
:class="{
|
||||
'cursor-pointer': item !== '...',
|
||||
'bg-surface-gray-4 border border-outline-gray-5 font-medium':
|
||||
activeQuestion == item,
|
||||
'bg-surface-gray-3 text-ink-gray-6':
|
||||
activeQuestion != item && item !== '...',
|
||||
'text-ink-gray-5': item === '...',
|
||||
'bg-surface-blue-3 text-ink-white':
|
||||
attemptedQuestions.includes(item) && activeQuestion != item,
|
||||
}"
|
||||
@click="item !== '...' && switchQuestion(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
@click="switchQuestion(activeQuestion + 1)"
|
||||
:disabled="activeQuestion == questions.length"
|
||||
class="rounded-full"
|
||||
>
|
||||
<template #icon>
|
||||
<ChevronRight class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
v-if="
|
||||
@@ -230,14 +291,16 @@
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="activeQuestion != questions.length"
|
||||
v-else-if="
|
||||
activeQuestion != questions.length && quiz.data.show_answers
|
||||
"
|
||||
@click="nextQuestion()"
|
||||
>
|
||||
<span>
|
||||
{{ __('Next') }}
|
||||
</span>
|
||||
</Button>
|
||||
<Button v-else @click="submitQuiz()">
|
||||
<Button variant="solid" v-else @click="handleSubmitClick()">
|
||||
<span>
|
||||
{{ __('Submit') }}
|
||||
</span>
|
||||
@@ -245,8 +308,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="reviewQuestions.length" class="border rounded-lg p-4 mt-4">
|
||||
<div class="font-semibold">
|
||||
{{ __('Questions marked for review') }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 mt-2">
|
||||
<div
|
||||
v-for="index in reviewQuestions"
|
||||
@click="activeQuestion = index"
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center text-sm cursor-pointer bg-surface-gray-3"
|
||||
>
|
||||
{{ index }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="border rounded-md p-20 text-center space-y-2">
|
||||
<div v-else class="border rounded-lg p-20 text-center space-y-2">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Quiz Summary') }}
|
||||
</div>
|
||||
@@ -310,30 +387,96 @@
|
||||
</ListView>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
v-model="showSubmissionConfirmation"
|
||||
:options="{
|
||||
title: __('Are you sure you want to submit the quiz?'),
|
||||
actions: [
|
||||
{
|
||||
size: 'sm',
|
||||
label: __('Submit'),
|
||||
variant: 'solid',
|
||||
onClick() {
|
||||
submitQuiz()
|
||||
showSubmissionConfirmation = false
|
||||
},
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="border border-outline-gray-modals rounded-lg text-base">
|
||||
<div class="divide-y divide-outline-gray-modals">
|
||||
<div class="grid grid-cols-2 divide-x divide-outline-gray-modals">
|
||||
<div class="p-2">
|
||||
{{ __('Total Questions') }}
|
||||
</div>
|
||||
<div class="p-2">
|
||||
{{ questions.length }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 divide-x divide-outline-gray-modals">
|
||||
<div class="p-2">
|
||||
{{ __('Attempted Questions') }}
|
||||
</div>
|
||||
<div class="p-2">
|
||||
{{ attemptedQuestions.length }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 divide-x divide-outline-gray-modals">
|
||||
<div class="p-2">
|
||||
{{ __('Unattempted Questions') }}
|
||||
</div>
|
||||
<div class="p-2">
|
||||
{{ questions.length - attemptedQuestions.length }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
call,
|
||||
Checkbox,
|
||||
createResource,
|
||||
Dialog,
|
||||
ListView,
|
||||
TextEditor,
|
||||
FormControl,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { ref, watch, reactive, inject, computed } from 'vue'
|
||||
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
reactive,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue'
|
||||
import {
|
||||
CheckCircle,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
XCircle,
|
||||
MinusCircle,
|
||||
} from 'lucide-vue-next'
|
||||
import { timeAgo } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const activeQuestion = ref(0)
|
||||
const currentQuestion = ref('')
|
||||
const selectedOptions = reactive([0, 0, 0, 0])
|
||||
const selectedOptions = ref([0, 0, 0, 0])
|
||||
const showAnswers = reactive([])
|
||||
let questions = reactive([])
|
||||
const attemptedQuestions = ref([])
|
||||
const reviewQuestions = ref([])
|
||||
const showSubmissionConfirmation = ref(false)
|
||||
const possibleAnswer = ref(null)
|
||||
const timer = ref(0)
|
||||
let timerInterval = null
|
||||
@@ -353,6 +496,40 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('pagehide', handlePageHide)
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('pagehide', handlePageHide)
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
const handlePageHide = () => {
|
||||
if (activeQuestion.value > 0 && !quizSubmission.data) {
|
||||
const params = new URLSearchParams({
|
||||
quiz: quiz.data.name,
|
||||
results: localStorage.getItem(quiz.data.title),
|
||||
})
|
||||
|
||||
navigator.sendBeacon(
|
||||
'/api/method/lms.lms.doctype.lms_quiz.lms_quiz.submit_quiz?' +
|
||||
params.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBeforeUnload = (event) => {
|
||||
if (activeQuestion.value > 0 && !quizSubmission.data) {
|
||||
if (attemptedQuestions.value.length) {
|
||||
switchQuestion(activeQuestion.value)
|
||||
}
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
}
|
||||
}
|
||||
|
||||
const quiz = createResource({
|
||||
url: 'frappe.client.get',
|
||||
makeParams(values) {
|
||||
@@ -465,7 +642,7 @@ watch(
|
||||
)
|
||||
|
||||
const quizSubmission = createResource({
|
||||
url: 'lms.lms.doctype.lms_quiz.lms_quiz.quiz_summary',
|
||||
url: 'lms.lms.doctype.lms_quiz.lms_quiz.submit_quiz',
|
||||
makeParams(values) {
|
||||
return {
|
||||
quiz: quiz.data.name,
|
||||
@@ -486,10 +663,58 @@ const questionDetails = createResource({
|
||||
watch(activeQuestion, (value) => {
|
||||
if (value > 0) {
|
||||
currentQuestion.value = quiz.data.questions[value - 1].question
|
||||
questionDetails.reload()
|
||||
questionDetails.reload(
|
||||
{},
|
||||
{
|
||||
onSuccess() {
|
||||
if (!quiz.data.show_answers) {
|
||||
loadSavedAnswers()
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const switchQuestion = (questionNumber) => {
|
||||
let answers = getAnswers()
|
||||
if (answers.length) {
|
||||
if (!attemptedQuestions.value.includes(activeQuestion.value)) {
|
||||
attemptedQuestions.value.push(activeQuestion.value)
|
||||
}
|
||||
addToLocalStorage()
|
||||
resetQuestion()
|
||||
}
|
||||
|
||||
if (questionNumber < 1 || questionNumber > questions.length) return
|
||||
activeQuestion.value = questionNumber
|
||||
}
|
||||
|
||||
const loadSavedAnswers = () => {
|
||||
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
|
||||
if (quizData) {
|
||||
let localQuestion = quizData.find(
|
||||
(q) => q.question_name == currentQuestion.value
|
||||
)
|
||||
if (localQuestion) {
|
||||
let localAnswers = localQuestion.answer
|
||||
if (localAnswers.length) {
|
||||
if (questionDetails.data.type == 'Choices') {
|
||||
localAnswers.forEach((answer) => {
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
if (questionDetails.data[`option_${i}`] == answer) {
|
||||
selectedOptions.value[i - 1] = 1
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
possibleAnswer.value = localAnswers[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.quizName,
|
||||
(newName) => {
|
||||
@@ -507,17 +732,20 @@ const startQuiz = () => {
|
||||
|
||||
const markAnswer = (index) => {
|
||||
if (!questionDetails.data.multiple)
|
||||
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
|
||||
selectedOptions[index - 1] = selectedOptions[index - 1] ? 0 : 1
|
||||
selectedOptions.value.splice(
|
||||
0,
|
||||
selectedOptions.value.length,
|
||||
...[0, 0, 0, 0]
|
||||
)
|
||||
selectedOptions.value[index - 1] = selectedOptions.value[index - 1] ? 0 : 1
|
||||
}
|
||||
|
||||
const getAnswers = () => {
|
||||
let answers = []
|
||||
const type = questionDetails.data.type
|
||||
|
||||
if (type == 'Choices') {
|
||||
selectedOptions.forEach((value, index) => {
|
||||
if (selectedOptions[index])
|
||||
selectedOptions.value.forEach((value, index) => {
|
||||
if (selectedOptions.value[index])
|
||||
answers.push(questionDetails.data[`option_${index + 1}`])
|
||||
})
|
||||
} else {
|
||||
@@ -538,14 +766,14 @@ const checkAnswer = () => {
|
||||
url: 'lms.lms.doctype.lms_quiz.lms_quiz.check_answer',
|
||||
params: {
|
||||
question: currentQuestion.value,
|
||||
type: questionDetails.data.type,
|
||||
question_type: questionDetails.data.type,
|
||||
answers: JSON.stringify(answers),
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
let type = questionDetails.data.type
|
||||
if (type == 'Choices') {
|
||||
selectedOptions.forEach((option, index) => {
|
||||
selectedOptions.value.forEach((option, index) => {
|
||||
if (option) {
|
||||
showAnswers[index] = option && data[index]
|
||||
} else if (data[index] == 2) {
|
||||
@@ -569,17 +797,15 @@ const addToLocalStorage = () => {
|
||||
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
|
||||
let questionData = {
|
||||
question_name: currentQuestion.value,
|
||||
answer: getAnswers().join(),
|
||||
is_correct: showAnswers.filter((answer) => {
|
||||
return answer != undefined
|
||||
}),
|
||||
answer: getAnswers(),
|
||||
}
|
||||
|
||||
if (quizData) {
|
||||
let existingQuestion = quizData.find(
|
||||
(q) => q.question_name == questionData.question_name
|
||||
)
|
||||
if (!existingQuestion) {
|
||||
if (existingQuestion) {
|
||||
existingQuestion.answer = questionData.answer
|
||||
} else {
|
||||
quizData.push(questionData)
|
||||
}
|
||||
} else {
|
||||
@@ -589,18 +815,15 @@ const addToLocalStorage = () => {
|
||||
}
|
||||
|
||||
const nextQuestion = () => {
|
||||
if (!quiz.data.show_answers && questionDetails.data?.type != 'Open Ended') {
|
||||
checkAnswer()
|
||||
} else {
|
||||
if (questionDetails.data?.type == 'Open Ended') addToLocalStorage()
|
||||
resetQuestion()
|
||||
}
|
||||
if (!quiz.data.show_answers) return
|
||||
if (questionDetails.data?.type == 'Open Ended') addToLocalStorage()
|
||||
resetQuestion()
|
||||
}
|
||||
|
||||
const resetQuestion = () => {
|
||||
if (activeQuestion.value == quiz.data.questions.length) return
|
||||
activeQuestion.value = activeQuestion.value + 1
|
||||
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
|
||||
selectedOptions.value.splice(0, selectedOptions.value.length, ...[0, 0, 0, 0])
|
||||
showAnswers.length = 0
|
||||
possibleAnswer.value = null
|
||||
}
|
||||
@@ -608,7 +831,6 @@ const resetQuestion = () => {
|
||||
const submitQuiz = () => {
|
||||
if (!quiz.data.show_answers) {
|
||||
if (questionDetails.data.type == 'Open Ended') addToLocalStorage()
|
||||
else checkAnswer()
|
||||
setTimeout(() => {
|
||||
createSubmission()
|
||||
}, 500)
|
||||
@@ -642,8 +864,10 @@ const createSubmission = () => {
|
||||
|
||||
const resetQuiz = () => {
|
||||
activeQuestion.value = 0
|
||||
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
|
||||
selectedOptions.value.splice(0, selectedOptions.value.length, ...[0, 0, 0, 0])
|
||||
showAnswers.length = 0
|
||||
possibleAnswer.value = null
|
||||
attemptedQuestions.value = []
|
||||
quizSubmission.reset()
|
||||
populateQuestions()
|
||||
setupTimer()
|
||||
@@ -672,6 +896,49 @@ const markLessonProgress = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitClick = () => {
|
||||
if (attemptedQuestions.value.length) {
|
||||
switchQuestion(activeQuestion.value)
|
||||
}
|
||||
showSubmissionConfirmation.value = true
|
||||
}
|
||||
|
||||
const paginationWindow = computed(() => {
|
||||
const total = questions.length
|
||||
const current = activeQuestion.value
|
||||
const pages = []
|
||||
const size = 5
|
||||
|
||||
let start = Math.floor((current - 1) / size) * size + 1
|
||||
let end = Math.min(start + size - 1, total)
|
||||
|
||||
if (start > 1) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
if (end < total) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
return pages
|
||||
})
|
||||
|
||||
const markForReview = (event, questionNumber) => {
|
||||
if (event.target.checked) {
|
||||
if (!reviewQuestions.value.includes(questionNumber)) {
|
||||
reviewQuestions.value.push(questionNumber)
|
||||
}
|
||||
} else {
|
||||
reviewQuestions.value = reviewQuestions.value.filter(
|
||||
(num) => num !== questionNumber
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const getSubmissionColumns = () => {
|
||||
return [
|
||||
{
|
||||
@@ -700,8 +967,3 @@ const getSubmissionColumns = () => {
|
||||
]
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
p {
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,11 +9,7 @@
|
||||
<template #body-content>
|
||||
<div class="grid grid-cols-2 gap-x-5">
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
v-model="badge.enabled"
|
||||
:label="__('Enabled')"
|
||||
type="checkbox"
|
||||
/>
|
||||
<Switch size="sm" v-model="badge.enabled" :label="__('Enabled')" />
|
||||
<FormControl
|
||||
v-model="badge.title"
|
||||
:label="__('Title')"
|
||||
@@ -41,10 +37,11 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="badge.grant_only_once"
|
||||
:label="__('Grant Only Once')"
|
||||
type="checkbox"
|
||||
:description="__('Each user can only receive this badge one time.')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="badge.event"
|
||||
@@ -82,7 +79,7 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, call, Dialog, FormControl, toast } from 'frappe-ui'
|
||||
import { Button, call, Dialog, FormControl, Switch, toast } from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { cleanError } from '@/utils'
|
||||
import type { Badges, Badge } from '@/components/Settings/types'
|
||||
@@ -206,7 +203,7 @@ const referenceDoctypeOptions = computed(() => {
|
||||
})
|
||||
|
||||
const eventOptions = computed(() => {
|
||||
let options = ['New', 'Value Change', 'Auto Assign']
|
||||
let options = ['New', 'Value Change', 'Manual Assignment']
|
||||
return options.map((event) => ({ label: __(event), value: event }))
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex flex-col h-full text-p-base">
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold mb-1 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
<div class="space-y-2">
|
||||
<div class="font-semibold text-xl text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Badge
|
||||
@@ -21,9 +26,6 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-ink-gray-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-y-auto">
|
||||
<SettingFields :sections="sections" :data="branding.data" />
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-scroll">
|
||||
<div class="divide-y space-y-2">
|
||||
<div class="divide-y divide-outline-gray-modals space-y-2">
|
||||
<div
|
||||
v-for="(cat, index) in categories.data"
|
||||
:key="cat.name"
|
||||
@@ -53,9 +53,9 @@
|
||||
>
|
||||
<div
|
||||
v-if="editing?.name !== cat.name"
|
||||
class="flex items-center justify-between group text-sm"
|
||||
class="flex items-center justify-between group text-sm text-ink-gray-9"
|
||||
>
|
||||
<div @dblclick="allowEdit(cat, index)">
|
||||
<div class="text-ink-gray-9" @dblclick="allowEdit(cat, index)">
|
||||
{{ cat.category }}
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -11,10 +11,11 @@
|
||||
</div>
|
||||
<div class="space-y-4 overflow-y-auto">
|
||||
<div>
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="data.enabled"
|
||||
:label="__('Enabled')"
|
||||
type="checkbox"
|
||||
:description="__('Allow this coupon to be used for discounts.')"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
@@ -81,7 +82,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, FormControl, toast } from 'frappe-ui'
|
||||
import { Button, FormControl, toast, Switch } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
import { ChevronLeft } from 'lucide-vue-next'
|
||||
import type { Coupon, Coupons } from './types'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
@@ -10,17 +10,49 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2">
|
||||
<Button variant="solid" @click="() => (showForm = !showForm)">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
<Dropdown
|
||||
placement="right"
|
||||
side="bottom"
|
||||
:options="[
|
||||
{
|
||||
label: __('New Evaluator'),
|
||||
icon: 'user-plus',
|
||||
onClick() {
|
||||
showNewEvaluator = true
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __('Existing User'),
|
||||
icon: 'user-check',
|
||||
onClick() {
|
||||
showExistingUser = true
|
||||
},
|
||||
},
|
||||
]"
|
||||
>
|
||||
<template v-slot="{ open }">
|
||||
<Button variant="solid">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
<template #suffix>
|
||||
<ChevronDown
|
||||
:class="[
|
||||
'w-4 h-4 stroke-1.5 ml-1 transform transition-transform',
|
||||
open ? 'rotate-180' : '',
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pb-5">
|
||||
<FormControl
|
||||
v-if="evaluators.data?.length > 0 || search"
|
||||
v-model="search"
|
||||
:placeholder="__('Search')"
|
||||
type="text"
|
||||
@@ -31,8 +63,8 @@
|
||||
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
|
||||
</template>
|
||||
</FormControl>
|
||||
<div class="overflow-auto h-[60vh]">
|
||||
<div class="divide-y">
|
||||
<div class="overflow-auto max-h-[60vh]">
|
||||
<div class="divide-y divide-outline-gray-modals">
|
||||
<div
|
||||
v-for="evaluator in evaluators.data"
|
||||
:key="evaluator.evaluator"
|
||||
@@ -70,11 +102,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="evaluators.length && hasNextPage"
|
||||
class="flex justify-center mt-4"
|
||||
>
|
||||
<Button @click="evaluators.reload()">
|
||||
<div v-if="evaluators.hasNextPage" class="flex justify-center mt-4">
|
||||
<Button @click="evaluators.next()">
|
||||
<template #prefix>
|
||||
<RefreshCw class="h-3 w-3 stroke-1.5" />
|
||||
</template>
|
||||
@@ -84,33 +113,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
v-model="showForm"
|
||||
:options="{
|
||||
size: 'xl',
|
||||
title: __('Add Evaluator'),
|
||||
actions: [{
|
||||
label: __('Add'),
|
||||
variant: 'solid',
|
||||
onClick({ close }: any) {
|
||||
addEvaluator(close)
|
||||
},
|
||||
}]
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div v-if="showForm" class="flex items-center">
|
||||
<FormControl
|
||||
v-model="email"
|
||||
:label="__('Email')"
|
||||
placeholder="jane@doe.com"
|
||||
type="email"
|
||||
class="w-full"
|
||||
@keydown.enter="addEvaluator"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
<AddEvaluatorModal v-model="showExistingUser" @added="evaluators.reload()" />
|
||||
<NewMemberModal
|
||||
v-model="showNewEvaluator"
|
||||
:defaultRoles="['batch_evaluator']"
|
||||
@created="onMemberCreated"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
@@ -118,18 +126,19 @@ import {
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
Dialog,
|
||||
Dropdown,
|
||||
FormControl,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { Plus, Search, Trash2, RefreshCw } from 'lucide-vue-next'
|
||||
import { Plus, Search, Trash2, RefreshCw, ChevronDown } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import NewMemberModal from '@/components/Modals/NewMemberModal.vue'
|
||||
import AddEvaluatorModal from '@/components/Modals/AddEvaluatorModal.vue'
|
||||
|
||||
const show = defineModel('show')
|
||||
const search = ref('')
|
||||
const showForm = ref(false)
|
||||
const email = ref('')
|
||||
const showExistingUser = ref(false)
|
||||
const showNewEvaluator = ref(false)
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
@@ -150,20 +159,8 @@ const evaluators = createListResource({
|
||||
orderBy: 'creation desc',
|
||||
})
|
||||
|
||||
const addEvaluator = (close: () => void) => {
|
||||
call('lms.lms.api.add_an_evaluator', {
|
||||
email: email.value,
|
||||
})
|
||||
.then(() => {
|
||||
email.value = ''
|
||||
evaluators.reload()
|
||||
toast.success(__('Evaluator added successfully'))
|
||||
close()
|
||||
})
|
||||
.catch((error: any) => {
|
||||
toast.error(__(error.messages[0] || error.messages))
|
||||
console.error('Error adding evaluator:', error)
|
||||
})
|
||||
const onMemberCreated = () => {
|
||||
evaluators.reload()
|
||||
}
|
||||
|
||||
watch(search, () => {
|
||||
@@ -176,7 +173,6 @@ watch(search, () => {
|
||||
})
|
||||
|
||||
const openProfile = (username: string) => {
|
||||
show.value = false
|
||||
router.push({
|
||||
name: 'Profile',
|
||||
params: {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title:
|
||||
accountID === 'new'
|
||||
? __('New Google Meet Account')
|
||||
: __('Edit Google Meet Account'),
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
label: __('Save'),
|
||||
variant: 'solid',
|
||||
onClick: ({ close }) => {
|
||||
saveAccount(close)
|
||||
},
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="mb-4">
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="account.enabled"
|
||||
:label="__('Enabled')"
|
||||
:description="
|
||||
__('Activate this Google Meet account for scheduling meetings.')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<FormControl
|
||||
v-model="account.name"
|
||||
:label="__('Account Name')"
|
||||
type="text"
|
||||
:required="true"
|
||||
/>
|
||||
<Link
|
||||
v-model="account.member"
|
||||
:label="__('Member')"
|
||||
doctype="Course Evaluator"
|
||||
:onCreate="(value: string, close: () => void) => openSettings('Members', close)"
|
||||
:required="true"
|
||||
/>
|
||||
<Link
|
||||
v-model="account.google_calendar"
|
||||
:label="__('Google Calendar')"
|
||||
doctype="Google Calendar"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { call, Dialog, FormControl, Switch, toast } from 'frappe-ui'
|
||||
import { inject, reactive, watch } from 'vue'
|
||||
import { User } from '@/components/Settings/types'
|
||||
import { openSettings, cleanError } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { useTelemetry } from 'frappe-ui/frappe'
|
||||
|
||||
interface GoogleMeetAccount {
|
||||
name: string
|
||||
account_name: string
|
||||
enabled: boolean
|
||||
member: string
|
||||
google_calendar: string
|
||||
}
|
||||
|
||||
interface GoogleMeetAccounts {
|
||||
data: GoogleMeetAccount[]
|
||||
reload: () => void
|
||||
insert: {
|
||||
submit: (
|
||||
data: GoogleMeetAccount,
|
||||
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||
) => void
|
||||
}
|
||||
setValue: {
|
||||
submit: (
|
||||
data: GoogleMeetAccount,
|
||||
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||
) => void
|
||||
}
|
||||
}
|
||||
|
||||
const show = defineModel('show')
|
||||
const user = inject<User | null>('$user')
|
||||
const googleMeetAccounts = defineModel<GoogleMeetAccounts>('googleMeetAccounts')
|
||||
const { capture } = useTelemetry()
|
||||
|
||||
const account = reactive({
|
||||
name: '',
|
||||
enabled: false,
|
||||
member: user?.data?.name || '',
|
||||
google_calendar: '',
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
accountID: {
|
||||
type: String,
|
||||
default: 'new',
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.accountID,
|
||||
(val) => {
|
||||
if (val === 'new') {
|
||||
account.name = ''
|
||||
account.enabled = false
|
||||
account.member = user?.data?.name || ''
|
||||
account.google_calendar = ''
|
||||
} else if (val && val !== 'new') {
|
||||
const acc = googleMeetAccounts.value?.data.find((acc) => acc.name === val)
|
||||
if (acc) {
|
||||
account.name = acc.name
|
||||
account.enabled = acc.enabled || false
|
||||
account.member = acc.member
|
||||
account.google_calendar = acc.google_calendar
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const saveAccount = (close: () => void) => {
|
||||
if (props.accountID == 'new') {
|
||||
createAccount(close)
|
||||
} else {
|
||||
updateAccount(close)
|
||||
}
|
||||
}
|
||||
|
||||
const createAccount = (close: () => void) => {
|
||||
googleMeetAccounts.value?.insert.submit(
|
||||
{
|
||||
account_name: account.name,
|
||||
...account,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
capture('google_meet_account_linked')
|
||||
googleMeetAccounts.value?.reload()
|
||||
close()
|
||||
toast.success(__('Google Meet Account created successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
console.error(err)
|
||||
close()
|
||||
toast.error(
|
||||
cleanError(err.messages[0]) ||
|
||||
__('Error creating Google Meet Account')
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateAccount = async (close: () => void) => {
|
||||
if (props.accountID != account.name) {
|
||||
await renameDoc()
|
||||
}
|
||||
setValue(close)
|
||||
}
|
||||
|
||||
const renameDoc = async () => {
|
||||
await call('frappe.client.rename_doc', {
|
||||
doctype: 'LMS Google Meet Settings',
|
||||
old_name: props.accountID,
|
||||
new_name: account.name,
|
||||
})
|
||||
}
|
||||
|
||||
const setValue = (close: () => void) => {
|
||||
googleMeetAccounts.value?.setValue.submit(
|
||||
{
|
||||
...account,
|
||||
name: account.name,
|
||||
account_name: props.accountID,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
googleMeetAccounts.value?.reload()
|
||||
close()
|
||||
toast.success(__('Google Meet Account updated successfully'))
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error(err)
|
||||
close()
|
||||
toast.error(
|
||||
cleanError(err.messages[0]) ||
|
||||
__('Error updating Google Meet Account')
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<div class="flex flex-col min-h-0 text-base">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ label }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-5">
|
||||
<Button @click="openForm('new')">
|
||||
<template #prefix>
|
||||
<Plus class="h-3 w-3 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="googleMeetAccounts.data?.length" class="overflow-y-scroll">
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="googleMeetAccounts.data"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
onRowClick: (row) => {
|
||||
openForm(row.name)
|
||||
},
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in columns">
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon
|
||||
v-if="item.icon"
|
||||
:name="item.icon"
|
||||
class="h-4 w-4 stroke-1.5"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in googleMeetAccounts.data">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<template #prefix>
|
||||
<div v-if="column.key == 'member_name'">
|
||||
<Avatar
|
||||
class="flex items-center"
|
||||
:image="row['member_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="column.key == 'enabled'">
|
||||
<Badge v-if="row[column.key]" theme="green">
|
||||
{{ __('Enabled') }}
|
||||
</Badge>
|
||||
<Badge v-else theme="gray">
|
||||
{{ __('Disabled') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div v-else class="leading-5 text-sm">
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="removeAccount(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
</div>
|
||||
<GoogleMeetAccountModal
|
||||
v-model="showForm"
|
||||
v-model:googleMeetAccounts="googleMeetAccounts"
|
||||
:accountID="currentAccount"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Badge,
|
||||
call,
|
||||
createListResource,
|
||||
FeatherIcon,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
ListSelectBanner,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { cleanError } from '@/utils'
|
||||
import { User } from '@/components/Settings/types'
|
||||
import GoogleMeetAccountModal from '@/components/Settings/GoogleMeetAccountModal.vue'
|
||||
|
||||
const user = inject<User | null>('$user')
|
||||
const showForm = ref(false)
|
||||
const currentAccount = ref<string | null>(null)
|
||||
|
||||
const props = defineProps({
|
||||
label: String,
|
||||
description: String,
|
||||
})
|
||||
|
||||
const googleMeetAccounts = createListResource({
|
||||
doctype: 'LMS Google Meet Settings',
|
||||
fields: [
|
||||
'name',
|
||||
'enabled',
|
||||
'member',
|
||||
'member_name',
|
||||
'member_image',
|
||||
'google_calendar',
|
||||
],
|
||||
cache: ['googleMeetAccounts'],
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchGoogleMeetAccounts()
|
||||
})
|
||||
|
||||
const fetchGoogleMeetAccounts = () => {
|
||||
if (!user?.data?.is_moderator && !user?.data?.is_evaluator) return
|
||||
|
||||
if (!user?.data?.is_moderator) {
|
||||
googleMeetAccounts.update({
|
||||
filters: {
|
||||
member: user.data.name,
|
||||
},
|
||||
})
|
||||
}
|
||||
googleMeetAccounts.reload()
|
||||
}
|
||||
|
||||
const openForm = (accountID: string) => {
|
||||
currentAccount.value = accountID
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
const removeAccount = (selections, unselectAll) => {
|
||||
call('lms.lms.api.delete_documents', {
|
||||
doctype: 'LMS Google Meet Settings',
|
||||
documents: Array.from(selections),
|
||||
})
|
||||
.then(() => {
|
||||
googleMeetAccounts.reload()
|
||||
toast.success(__('Google Meet Account deleted successfully'))
|
||||
unselectAll()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
cleanError(err.messages[0]) || __('Error deleting Google Meet Account')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const columns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Member'),
|
||||
key: 'member_name',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
label: __('Account Name'),
|
||||
key: 'name',
|
||||
icon: 'video',
|
||||
},
|
||||
{
|
||||
label: __('Status'),
|
||||
key: 'enabled',
|
||||
align: 'center',
|
||||
icon: 'check-square',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2">
|
||||
<Button variant="solid" @click="() => (showForm = !showForm)">
|
||||
<Button @click="showNewMember = true">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
@@ -31,8 +31,8 @@
|
||||
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
|
||||
</template>
|
||||
</FormControl>
|
||||
<div class="overflow-y-scroll h-[60vh]">
|
||||
<ul class="divide-y">
|
||||
<div class="overflow-y-scroll max-h-[60vh]">
|
||||
<ul class="divide-y divide-outline-gray-modals">
|
||||
<li
|
||||
v-for="member in memberList"
|
||||
class="flex items-center justify-between py-2 cursor-pointer"
|
||||
@@ -58,7 +58,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center space-x-1 bg-surface-gray-2 px-2 py-1.5 rounded-md"
|
||||
class="flex items-center text-ink-gray-9 space-x-1 bg-surface-gray-2 px-2 py-1.5 rounded-md"
|
||||
v-if="member.role && member.role !== 'LMS Student'"
|
||||
>
|
||||
<Shield class="size-4 stroke-1.5" />
|
||||
@@ -82,47 +82,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
v-model="showForm"
|
||||
:options="{
|
||||
title: __('Add a new member'),
|
||||
size: 'lg',
|
||||
actions: [{
|
||||
label: __('Add'),
|
||||
variant: 'solid',
|
||||
onClick({ close }: any) {
|
||||
addMember(close)
|
||||
}
|
||||
}]
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex items-center space-x-2">
|
||||
<FormControl
|
||||
v-model="member.email"
|
||||
:label="__('Email')"
|
||||
placeholder="jane@doe.com"
|
||||
type="email"
|
||||
class="w-full"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="member.first_name"
|
||||
:label="__('First Name')"
|
||||
placeholder="Jane"
|
||||
type="text"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
<NewMemberModal v-model="showNewMember" @created="onMemberCreated" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Avatar, Button, createResource, Dialog, FormControl } from 'frappe-ui'
|
||||
import { Avatar, Button, createResource, FormControl } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, watch, reactive, inject } from 'vue'
|
||||
import { ref, watch, inject } from 'vue'
|
||||
import { RefreshCw, Plus, Search, Shield } from 'lucide-vue-next'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import type { User } from '@/components/Settings/types'
|
||||
import NewMemberModal from '@/components/Modals/NewMemberModal.vue'
|
||||
|
||||
type Member = {
|
||||
username: string
|
||||
@@ -138,14 +107,10 @@ const search = ref('')
|
||||
const start = ref(0)
|
||||
const memberList = ref<Member[]>([])
|
||||
const hasNextPage = ref(false)
|
||||
const showForm = ref(false)
|
||||
const showNewMember = ref(false)
|
||||
const user = inject<User | null>('$user')
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
|
||||
const member = reactive({
|
||||
email: '',
|
||||
first_name: '',
|
||||
})
|
||||
const { capture } = useTelemetry()
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
@@ -184,34 +149,12 @@ const openProfile = (username: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
const newMember = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams() {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'User',
|
||||
first_name: member.first_name,
|
||||
email: member.email,
|
||||
},
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data: Member) {
|
||||
show.value = false
|
||||
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
|
||||
|
||||
router.push({
|
||||
name: 'ProfileRoles',
|
||||
params: {
|
||||
username: data.username,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const addMember = (close: () => void) => {
|
||||
newMember.reload()
|
||||
close()
|
||||
const onMemberCreated = (data: any) => {
|
||||
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
|
||||
capture('user_added')
|
||||
memberList.value = []
|
||||
start.value = 0
|
||||
members.reload()
|
||||
}
|
||||
|
||||
watch(search, () => {
|
||||
|
||||
@@ -131,7 +131,7 @@ watch(newGateway, () => {
|
||||
let fields = gatewayFields.data || []
|
||||
arrangeFields(fields)
|
||||
newGatewayFields.value = makeSections(fields)
|
||||
prepareGatewayData()
|
||||
prepareGatewayData(fields)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -209,13 +209,11 @@ const allGatewayOptions = computed(() => {
|
||||
return options.map((gateway: string) => ({ label: gateway, value: gateway }))
|
||||
})
|
||||
|
||||
const prepareGatewayData = () => {
|
||||
const prepareGatewayData = (fields: any[]) => {
|
||||
newGatewayData.value = {}
|
||||
if (newGatewayFields.value.length) {
|
||||
newGatewayFields.value.forEach((field: any) => {
|
||||
newGatewayData.value[field.fieldname] = field.default || ''
|
||||
})
|
||||
}
|
||||
fields.forEach((field: any) => {
|
||||
newGatewayData.value[field.name] = field.default || ''
|
||||
})
|
||||
}
|
||||
|
||||
const makeSections = (fields: any[]) => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
@@ -88,6 +88,7 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
FeatherIcon,
|
||||
ListView,
|
||||
@@ -97,10 +98,12 @@ import {
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
ListSelectBanner,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import PaymentGatewayDetails from '@/components/Settings/PaymentGatewayDetails.vue'
|
||||
import { cleanError } from '@/utils'
|
||||
|
||||
const showForm = ref(false)
|
||||
const currentGateway = ref(null)
|
||||
@@ -128,6 +131,23 @@ const openForm = (gatewayID) => {
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
const removeAccount = (selections, unselectAll) => {
|
||||
call('lms.lms.api.delete_documents', {
|
||||
doctype: 'Payment Gateway',
|
||||
documents: Array.from(selections),
|
||||
})
|
||||
.then(() => {
|
||||
paymentGateways.reload()
|
||||
toast.success(__('Payment gateways deleted successfully'))
|
||||
unselectAll()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
cleanError(err.messages[0]) || __('Error deleting payment gateways')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const columns = computed(() => {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -2,23 +2,25 @@
|
||||
<div class="flex flex-col h-full text-base overflow-y-hidden">
|
||||
<div class="">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class="text-xl font-semibold leading-none text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Badge
|
||||
v-if="data.isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
<Button variant="solid" :loading="data.save.loading" @click="update">
|
||||
{{ __('Update') }}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="solid" :loading="data.save.loading" @click="update">
|
||||
{{ __('Update') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="mb-5 divide-y overflow-y-auto">
|
||||
<div class="mb-5 divide-y divide-outline-gray-modals overflow-y-auto">
|
||||
<div v-for="(section, index) in sections" class="py-5">
|
||||
<div v-if="section.label" class="font-semibold text-ink-gray-9 mb-4">
|
||||
{{ section.label }}
|
||||
@@ -20,6 +20,7 @@
|
||||
:doctype="field.doctype"
|
||||
:label="__(field.label)"
|
||||
:description="__(field.description)"
|
||||
:required="field.reqd"
|
||||
/>
|
||||
|
||||
<div v-else-if="field.type == 'Code'">
|
||||
@@ -65,7 +66,7 @@
|
||||
<div v-else>
|
||||
<div class="flex items-center text-sm space-x-2">
|
||||
<div
|
||||
class="flex items-center justify-center rounded border border-outline-gray-1 bg-surface-gray-2"
|
||||
class="flex items-center justify-center rounded border border-outline-gray-modals bg-surface-gray-2"
|
||||
:class="field.size == 'lg' ? 'px-5 py-5' : 'px-20 py-8'"
|
||||
>
|
||||
<img
|
||||
@@ -90,7 +91,7 @@
|
||||
</div>
|
||||
<X
|
||||
@click="data[field.name] = null"
|
||||
class="border text-ink-gray-7 border-outline-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
class="border text-ink-gray-7 border-outline-gray-modals rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,6 +116,7 @@
|
||||
:rows="field.rows"
|
||||
:options="field.options"
|
||||
:description="field.description"
|
||||
:required="field.reqd"
|
||||
placeholder=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -42,8 +42,7 @@
|
||||
...(activeTab.label == 'Branding'
|
||||
? { sections: activeTab.sections }
|
||||
: {}),
|
||||
...(activeTab.label == 'Evaluators' ||
|
||||
activeTab.label == 'Members' ||
|
||||
...(activeTab.label == 'Members' ||
|
||||
activeTab.label == 'Transactions'
|
||||
? { 'onUpdate:show': (val) => (show = val), show }
|
||||
: {}),
|
||||
@@ -76,6 +75,7 @@ import PaymentGateways from '@/components/Settings/PaymentGateways.vue'
|
||||
import Coupons from '@/components/Settings/Coupons/Coupons.vue'
|
||||
import Transactions from '@/components/Settings/Transactions/Transactions.vue'
|
||||
import ZoomSettings from '@/components/Settings/ZoomSettings.vue'
|
||||
import GoogleMeetSettings from '@/components/Settings/GoogleMeetSettings.vue'
|
||||
import Badges from '@/components/Settings/Badges.vue'
|
||||
|
||||
const show = defineModel()
|
||||
@@ -219,6 +219,25 @@ const tabsStructure = computed(() => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Jobs',
|
||||
columns: [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Allow Job Posting',
|
||||
name: 'allow_job_posting',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'If enabled, users can post job openings on the job board. Else only admins can post jobs.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fields: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '',
|
||||
columns: [
|
||||
@@ -249,34 +268,6 @@ const tabsStructure = computed(() => {
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Lists',
|
||||
hideLabel: false,
|
||||
items: [
|
||||
{
|
||||
label: 'Members',
|
||||
description:
|
||||
'Add new members or manage roles and permissions of existing members',
|
||||
icon: 'UserRoundPlus',
|
||||
template: markRaw(Members),
|
||||
},
|
||||
{
|
||||
label: 'Evaluators',
|
||||
description: '',
|
||||
icon: 'UserCheck',
|
||||
description:
|
||||
'Add new evaluators or check the slots existing evaluators',
|
||||
template: markRaw(Evaluators),
|
||||
},
|
||||
{
|
||||
label: 'Zoom Accounts',
|
||||
description:
|
||||
'Manage zoom accounts to conduct live classes from batches',
|
||||
icon: 'Video',
|
||||
template: markRaw(ZoomSettings),
|
||||
},
|
||||
{
|
||||
label: 'Badges',
|
||||
description:
|
||||
@@ -298,6 +289,27 @@ const tabsStructure = computed(() => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Users',
|
||||
hideLabel: false,
|
||||
items: [
|
||||
{
|
||||
label: 'Members',
|
||||
description:
|
||||
'Add new members or manage roles and permissions of existing members',
|
||||
icon: 'User',
|
||||
template: markRaw(Members),
|
||||
},
|
||||
{
|
||||
label: 'Evaluators',
|
||||
description: '',
|
||||
icon: 'UserCircle2',
|
||||
description:
|
||||
'Add new evaluators or check the slots of existing evaluators',
|
||||
template: markRaw(Evaluators),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Payment',
|
||||
hideLabel: false,
|
||||
@@ -318,29 +330,62 @@ const tabsStructure = computed(() => {
|
||||
doctype: 'Currency',
|
||||
},
|
||||
{
|
||||
label: 'Payment Gateway',
|
||||
name: 'payment_gateway',
|
||||
type: 'Link',
|
||||
doctype: 'Payment Gateway',
|
||||
label: 'Show USD equivalent amount',
|
||||
name: 'show_usd_equivalent',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'If enabled, it shows the USD equivalent amount for all transactions based on the current exchange rate.',
|
||||
},
|
||||
{
|
||||
label: 'Apply rounding on equivalent',
|
||||
name: 'apply_rounding',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'If enabled, it applies rounding on the USD equivalent amount.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Payment Gateway',
|
||||
name: 'payment_gateway',
|
||||
type: 'Link',
|
||||
doctype: 'Payment Gateway',
|
||||
},
|
||||
{
|
||||
label: 'Apply GST for India',
|
||||
name: 'apply_gst',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'If enabled, GST will be applied to the price for students from India.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Payment Reminders',
|
||||
columns: [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Show USD equivalent amount',
|
||||
name: 'show_usd_equivalent',
|
||||
label: 'Send payment reminders for batch',
|
||||
name: 'send_payment_reminders_for_batch',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'If enabled, it sends payment reminders to students who left the payment incomplete for a batch.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
label: 'Apply rounding on equivalent',
|
||||
name: 'apply_rounding',
|
||||
label: 'Send payment reminders for course',
|
||||
name: 'send_payment_reminders_for_course',
|
||||
type: 'checkbox',
|
||||
description:
|
||||
'If enabled, it sends payment reminders to students who left the payment incomplete for a course.',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -368,6 +413,26 @@ const tabsStructure = computed(() => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Conferencing',
|
||||
hideLabel: false,
|
||||
items: [
|
||||
{
|
||||
label: 'Zoom',
|
||||
description:
|
||||
'Manage zoom accounts to conduct live classes from batches',
|
||||
icon: 'Video',
|
||||
template: markRaw(ZoomSettings),
|
||||
},
|
||||
{
|
||||
label: 'Google Meet',
|
||||
description:
|
||||
'Manage Google Meet accounts to conduct live classes from batches',
|
||||
icon: 'Presentation',
|
||||
template: markRaw(GoogleMeetSettings),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Customize',
|
||||
hideLabel: false,
|
||||
@@ -375,6 +440,8 @@ const tabsStructure = computed(() => {
|
||||
{
|
||||
label: 'Branding',
|
||||
icon: 'Blocks',
|
||||
description:
|
||||
'Customize the brand name and logo to make the application your own',
|
||||
template: markRaw(BrandSettings),
|
||||
sections: [
|
||||
{
|
||||
@@ -463,6 +530,8 @@ const tabsStructure = computed(() => {
|
||||
{
|
||||
label: 'Signup',
|
||||
icon: 'LogIn',
|
||||
description:
|
||||
'Manage the settings related to user signup and registration',
|
||||
sections: [
|
||||
{
|
||||
columns: [
|
||||
@@ -498,6 +567,8 @@ const tabsStructure = computed(() => {
|
||||
{
|
||||
label: 'SEO',
|
||||
icon: 'Search',
|
||||
description:
|
||||
'Manage the SEO settings to improve your website ranking on search engines',
|
||||
sections: [
|
||||
{
|
||||
columns: [
|
||||
|
||||
@@ -32,14 +32,16 @@
|
||||
</div>
|
||||
<div v-if="transactionData" class="overflow-y-auto">
|
||||
<div class="grid grid-cols-3 gap-5">
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Payment Received')"
|
||||
type="checkbox"
|
||||
:description="__('Mark the payment as received.')"
|
||||
v-model="transactionData.payment_received"
|
||||
/>
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Payment For Certificate')"
|
||||
type="checkbox"
|
||||
:description="__('This payment is for a certificate.')"
|
||||
v-model="transactionData.payment_for_certificate"
|
||||
/>
|
||||
<FormControl
|
||||
@@ -55,17 +57,18 @@
|
||||
:label="__('Member')"
|
||||
doctype="User"
|
||||
v-model="transactionData.member"
|
||||
:required="true"
|
||||
:required="!!fieldMeta.member?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Billing Name')"
|
||||
v-model="transactionData.billing_name"
|
||||
:required="true"
|
||||
:required="!!fieldMeta.billing_name?.reqd"
|
||||
/>
|
||||
<Link
|
||||
:label="__('Source')"
|
||||
v-model="transactionData.source"
|
||||
doctype="LMS Source"
|
||||
:required="!!fieldMeta.source?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
type="select"
|
||||
@@ -73,12 +76,14 @@
|
||||
:label="__('Payment For Document Type')"
|
||||
v-model="transactionData.payment_for_document_type"
|
||||
doctype="DocType"
|
||||
:required="!!fieldMeta.payment_for_document_type?.reqd"
|
||||
/>
|
||||
<Link
|
||||
v-if="transactionData.payment_for_document_type"
|
||||
:label="__('Payment For Document')"
|
||||
v-model="transactionData.payment_for_document"
|
||||
:doctype="transactionData.payment_for_document_type"
|
||||
:required="!!fieldMeta.payment_for_document?.reqd"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -90,17 +95,18 @@
|
||||
:label="__('Currency')"
|
||||
v-model="transactionData.currency"
|
||||
doctype="Currency"
|
||||
:required="true"
|
||||
:required="!!fieldMeta.currency?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Amount')"
|
||||
v-model="transactionData.amount"
|
||||
:required="true"
|
||||
:required="!!fieldMeta.amount?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="transactionData.amount_with_gst"
|
||||
:label="__('Amount with GST')"
|
||||
v-model="transactionData.amount_with_gst"
|
||||
:required="!!fieldMeta.amount_with_gst?.reqd"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -113,21 +119,25 @@
|
||||
v-if="transactionData.coupon"
|
||||
:label="__('Coupon Code')"
|
||||
v-model="transactionData.coupon"
|
||||
:required="!!fieldMeta.coupon?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="transactionData.coupon"
|
||||
:label="__('Coupon Code')"
|
||||
v-model="transactionData.coupon_code"
|
||||
:required="!!fieldMeta.coupon_code?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="transactionData.coupon"
|
||||
:label="__('Discount Amount')"
|
||||
v-model="transactionData.discount_amount"
|
||||
:required="!!fieldMeta.discount_amount?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="transactionData.coupon"
|
||||
:label="__('Original Amount')"
|
||||
v-model="transactionData.original_amount"
|
||||
:required="!!fieldMeta.original_amount?.reqd"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,24 +150,34 @@
|
||||
:label="__('Address')"
|
||||
v-model="transactionData.address"
|
||||
doctype="Address"
|
||||
:required="true"
|
||||
:required="!!fieldMeta.address?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('GSTIN')"
|
||||
v-model="transactionData.gstin"
|
||||
:required="!!fieldMeta.gstin?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('PAN')"
|
||||
v-model="transactionData.pan"
|
||||
:required="!!fieldMeta.pan?.reqd"
|
||||
/>
|
||||
<FormControl :label="__('GSTIN')" v-model="transactionData.gstin" />
|
||||
<FormControl :label="__('PAN')" v-model="transactionData.pan" />
|
||||
<FormControl
|
||||
:label="__('Payment ID')"
|
||||
v-model="transactionData.payment_id"
|
||||
:required="!!fieldMeta.payment_id?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Order ID')"
|
||||
v-model="transactionData.order_id"
|
||||
:required="!!fieldMeta.order_id?.reqd"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, FormControl, toast } from 'frappe-ui'
|
||||
import { Button, FormControl, Switch, toast } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ChevronLeft } from 'lucide-vue-next'
|
||||
@@ -171,6 +191,10 @@ const show = defineModel('show')
|
||||
const props = defineProps<{
|
||||
transactions: any
|
||||
data: any
|
||||
fieldMeta: Record<
|
||||
string,
|
||||
{ reqd?: number; default?: string; description?: string }
|
||||
>
|
||||
}>()
|
||||
|
||||
const saveTransaction = () => {
|
||||
@@ -211,48 +235,49 @@ const updateTransaction = () => {
|
||||
}
|
||||
|
||||
const openDetails = () => {
|
||||
if (props.data) {
|
||||
const docType = props.data.payment_for_document_type
|
||||
const docName = props.data.payment_for_document
|
||||
if (docType && docName) {
|
||||
router.push({
|
||||
name: docType == 'LMS Course' ? 'CourseDetail' : 'BatchDetail',
|
||||
params: {
|
||||
[docType == 'LMS Course' ? 'courseName' : 'batchName']: docName,
|
||||
},
|
||||
})
|
||||
}
|
||||
const docType = transactionData.value?.payment_for_document_type
|
||||
const docName = transactionData.value?.payment_for_document
|
||||
if (docType && docName) {
|
||||
router.push({
|
||||
name: docType == 'LMS Course' ? 'CourseDetail' : 'BatchDetail',
|
||||
params: {
|
||||
[docType == 'LMS Course' ? 'courseName' : 'batchName']: docName,
|
||||
},
|
||||
})
|
||||
show.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const emptyTransactionData = {
|
||||
const getDefault = (fieldname: string) =>
|
||||
props.fieldMeta[fieldname]?.default || null
|
||||
|
||||
const getEmptyTransactionData = () => ({
|
||||
payment_received: false,
|
||||
payment_for_certificate: false,
|
||||
member: null,
|
||||
billing_name: null,
|
||||
source: null,
|
||||
payment_for_document_type: null,
|
||||
payment_for_document: null,
|
||||
member: getDefault('member'),
|
||||
billing_name: getDefault('billing_name'),
|
||||
source: getDefault('source'),
|
||||
payment_for_document_type: getDefault('payment_for_document_type'),
|
||||
payment_for_document: getDefault('payment_for_document'),
|
||||
member_consent: false,
|
||||
currency: null,
|
||||
amount: null,
|
||||
amount_with_gst: null,
|
||||
coupon: null,
|
||||
coupon_code: null,
|
||||
discount_amount: null,
|
||||
original_amount: null,
|
||||
order_id: null,
|
||||
payment_id: null,
|
||||
gstin: null,
|
||||
pan: null,
|
||||
address: null,
|
||||
}
|
||||
currency: getDefault('currency'),
|
||||
amount: getDefault('amount'),
|
||||
amount_with_gst: getDefault('amount_with_gst'),
|
||||
coupon: getDefault('coupon'),
|
||||
coupon_code: getDefault('coupon_code'),
|
||||
discount_amount: getDefault('discount_amount'),
|
||||
original_amount: getDefault('original_amount'),
|
||||
order_id: getDefault('order_id'),
|
||||
payment_id: getDefault('payment_id'),
|
||||
gstin: getDefault('gstin'),
|
||||
pan: getDefault('pan'),
|
||||
address: getDefault('address'),
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(newVal) => {
|
||||
transactionData.value = newVal ? { ...newVal } : emptyTransactionData
|
||||
transactionData.value = newVal ? { ...newVal } : getEmptyTransactionData()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
<div class="text-xl font-semibold mb-2 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
@@ -27,15 +27,17 @@
|
||||
doctype="User"
|
||||
:placeholder="__('Filter by Member')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="paymentReceived"
|
||||
type="checkbox"
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Payment Received')"
|
||||
:description="__('Mark the payment as received.')"
|
||||
v-model="paymentReceived"
|
||||
/>
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Payment For Certificate')"
|
||||
:description="__('This payment is for a certificate.')"
|
||||
v-model="paymentForCertificate"
|
||||
type="checkbox"
|
||||
:label="__('Payment for Certificate')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -116,6 +118,7 @@ import {
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
FormControl,
|
||||
Switch,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { RefreshCw } from 'lucide-vue-next'
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
v-if="step == 'new'"
|
||||
:transactions="transactions"
|
||||
:data="data"
|
||||
:fieldMeta="fieldMeta.data || {}"
|
||||
v-model:show="show"
|
||||
@updateStep="updateStep"
|
||||
/>
|
||||
@@ -17,13 +18,14 @@
|
||||
v-else-if="step == 'details'"
|
||||
:transactions="transactions"
|
||||
:data="data"
|
||||
:fieldMeta="fieldMeta.data || {}"
|
||||
v-model:show="show"
|
||||
@updateStep="updateStep"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { createListResource } from 'frappe-ui'
|
||||
import { createListResource, createResource } from 'frappe-ui'
|
||||
import TransactionList from '@/components/Settings/Transactions/TransactionList.vue'
|
||||
import TransactionDetails from '@/components/Settings/Transactions/TransactionDetails.vue'
|
||||
|
||||
@@ -45,6 +47,11 @@ const updateStep = (newStep: 'list' | 'new' | 'edit', newData: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const fieldMeta = createResource({
|
||||
url: 'lms.lms.api.get_payment_field_meta',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const transactions = createListResource({
|
||||
doctype: 'LMS Payment',
|
||||
fields: [
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{{ label }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
{{ __(description || '') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-5">
|
||||
@@ -90,6 +90,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<ZoomAccountModal
|
||||
v-if="showForm"
|
||||
v-model="showForm"
|
||||
v-model:zoomAccounts="zoomAccounts"
|
||||
:accountID="currentAccount"
|
||||
@@ -100,7 +101,6 @@ import {
|
||||
Avatar,
|
||||
Button,
|
||||
Badge,
|
||||
call,
|
||||
createListResource,
|
||||
FeatherIcon,
|
||||
ListView,
|
||||
@@ -112,20 +112,18 @@ import {
|
||||
ListSelectBanner,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { cleanError } from '@/utils'
|
||||
import { User } from '@/components/Settings/types'
|
||||
import ZoomAccountModal from '@/components/Modals/ZoomAccountModal.vue'
|
||||
|
||||
const user = inject<User | null>('$user')
|
||||
const showForm = ref(false)
|
||||
const currentAccount = ref<string | null>(null)
|
||||
|
||||
const props = defineProps({
|
||||
label: String,
|
||||
description: String,
|
||||
})
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
description?: string
|
||||
}>()
|
||||
|
||||
const zoomAccounts = createListResource({
|
||||
doctype: 'LMS Zoom Settings',
|
||||
@@ -147,15 +145,6 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
const fetchZoomAccounts = () => {
|
||||
if (!user?.data?.is_moderator && !user?.data?.is_evaluator) return
|
||||
|
||||
if (!user?.data?.is_moderator) {
|
||||
zoomAccounts.update({
|
||||
filters: {
|
||||
member: user.data.name,
|
||||
},
|
||||
})
|
||||
}
|
||||
zoomAccounts.reload()
|
||||
}
|
||||
|
||||
@@ -164,21 +153,20 @@ const openForm = (accountID: string) => {
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
const removeAccount = (selections, unselectAll) => {
|
||||
call('lms.lms.api.delete_documents', {
|
||||
doctype: 'LMS Zoom Settings',
|
||||
documents: Array.from(selections),
|
||||
const removeAccount = (selections: Set<string>, unselectAll: () => void) => {
|
||||
Array.from(selections).forEach((accountID) => {
|
||||
zoomAccounts.delete.submit(accountID, {
|
||||
onSuccess() {
|
||||
toast.success(__('Zoom account deleted successfully'))
|
||||
fetchZoomAccounts()
|
||||
unselectAll()
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.error(cleanError(err.messages[0] || err))
|
||||
console.error(err)
|
||||
},
|
||||
})
|
||||
})
|
||||
.then(() => {
|
||||
zoomAccounts.reload()
|
||||
toast.success(__('Email Templates deleted successfully'))
|
||||
unselectAll()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
cleanError(err.messages[0]) || __('Error deleting email templates')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const columns = computed(() => {
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
:class="sidebarStore.isSidebarCollapsed ? 'w-14' : 'w-56'"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col overflow-hidden"
|
||||
class="flex flex-col overflow-y-auto"
|
||||
:class="sidebarStore.isSidebarCollapsed ? 'items-center' : ''"
|
||||
>
|
||||
<UserDropdown :isCollapsed="sidebarStore.isSidebarCollapsed" />
|
||||
<div class="flex flex-col" v-if="sidebarSettings.data">
|
||||
<div class="flex flex-col overflow-y-auto" v-if="sidebarSettings.data">
|
||||
<div v-for="link in sidebarLinks" class="mx-2 my-2.5">
|
||||
<div
|
||||
v-if="!link.hideLabel"
|
||||
@@ -37,7 +37,7 @@
|
||||
>
|
||||
<div
|
||||
v-if="!sidebarStore.isSidebarCollapsed"
|
||||
class="flex items-center text-sm text-ink-gray-5 my-1"
|
||||
class="flex items-center text-ink-gray-5 my-1"
|
||||
>
|
||||
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
||||
<ChevronRight
|
||||
@@ -90,6 +90,56 @@
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
isStudent && !profileIsComplete && !sidebarStore.isSidebarCollapsed
|
||||
"
|
||||
class="flex flex-col gap-3 text-ink-gray-9 py-2.5 px-3 bg-surface-white shadow-sm rounded-md"
|
||||
>
|
||||
<div class="flex flex-col text-p-sm gap-1">
|
||||
<div class="inline-flex gap-1">
|
||||
<User class="h-4 my-0.5 shrink-0" />
|
||||
<div class="font-medium">
|
||||
{{ __('Complete your profile') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-ink-gray-7 leading-5">
|
||||
{{ __('Highlight what makes you unique and show your skills.') }}
|
||||
</div>
|
||||
</div>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: {
|
||||
username: userResource.data?.username,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button :label="__('My Profile')" class="w-full">
|
||||
<template #prefix>
|
||||
<ChevronsRight class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
<Tooltip
|
||||
v-if="
|
||||
isStudent && !profileIsComplete && sidebarStore.isSidebarCollapsed
|
||||
"
|
||||
:text="__('Complete your profile')"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: {
|
||||
username: userResource.data?.username,
|
||||
},
|
||||
}"
|
||||
class="flex items-center justify-center"
|
||||
>
|
||||
<User class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer" />
|
||||
</router-link>
|
||||
</Tooltip>
|
||||
<TrialBanner
|
||||
v-if="
|
||||
userResource.data?.is_system_manager && userResource.data?.is_fc_site
|
||||
@@ -132,10 +182,13 @@
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Powered by Learning')">
|
||||
<Zap
|
||||
<Tooltip
|
||||
v-if="showAppointmentIcon"
|
||||
:text="__('Book a free onboarding session with the Frappe team')"
|
||||
>
|
||||
<Phone
|
||||
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||
@click="redirectToWebsite()"
|
||||
@click="redirectToAppointmentScreen()"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip v-if="showOnboarding" :text="__('Help')">
|
||||
@@ -149,6 +202,12 @@
|
||||
"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Powered by Frappe Learning')">
|
||||
<Zap
|
||||
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||
@click="redirectToWebsite()"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip
|
||||
:text="
|
||||
@@ -210,15 +269,19 @@ import {
|
||||
markRaw,
|
||||
h,
|
||||
onUnmounted,
|
||||
computed,
|
||||
} from 'vue'
|
||||
import {
|
||||
BookOpen,
|
||||
CircleAlert,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
ChevronsRight,
|
||||
CircleHelp,
|
||||
FolderTree,
|
||||
FileText,
|
||||
Phone,
|
||||
Plus,
|
||||
User,
|
||||
UserPlus,
|
||||
Users,
|
||||
BookText,
|
||||
@@ -613,6 +676,48 @@ const redirectToWebsite = () => {
|
||||
window.open('https://frappe.io/learning', '_blank')
|
||||
}
|
||||
|
||||
const isStudent = computed(() => {
|
||||
return userResource.data?.is_student
|
||||
})
|
||||
|
||||
const profileIsComplete = computed(() => {
|
||||
return (
|
||||
userResource.data?.user_image &&
|
||||
userResource.data?.headline &&
|
||||
userResource.data?.bio
|
||||
)
|
||||
})
|
||||
|
||||
const showAppointmentIcon = computed(() => {
|
||||
let isTrialPlan = userResource.data?.site_info?.plan?.is_trial_plan
|
||||
let trialEndDate = calculateTrialEndDays(
|
||||
userResource.data?.site_info?.trial_end_date
|
||||
)
|
||||
return (
|
||||
userResource.data?.is_system_manager &&
|
||||
userResource.data?.is_fc_site &&
|
||||
isTrialPlan &&
|
||||
trialEndDate > 0
|
||||
)
|
||||
})
|
||||
|
||||
const calculateTrialEndDays = (trialEndDate) => {
|
||||
if (!trialEndDate) return 0
|
||||
|
||||
trialEndDate = new Date(trialEndDate)
|
||||
const today = new Date()
|
||||
const diffTime = trialEndDate - today
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
return diffDays
|
||||
}
|
||||
|
||||
const redirectToAppointmentScreen = () => {
|
||||
window.open(
|
||||
'https://calendar.google.com/calendar/u/0/appointments/schedules/AcZssZ0c7Z3XIpW1WgbeIuktSaoX6qudoYuSdRbIlJty5TW7p4IZaOk5viHQGwTNi6HpNVqzOZOTHcle',
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
socket.off('publish_lms_notifications')
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg border border-gray-100 bg-surface-white shadow-xl"
|
||||
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<div v-for="app in apps.data" key="name">
|
||||
<a
|
||||
@@ -48,7 +48,7 @@ const apps = createResource({
|
||||
name: 'frappe',
|
||||
logo: '/assets/lms/images/desk.png',
|
||||
title: __('Desk'),
|
||||
route: '/desk/lms',
|
||||
route: '/desk/learning',
|
||||
},
|
||||
]
|
||||
data.map((app) => {
|
||||
|
||||
@@ -1,26 +1,48 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-3 justify-between bg-surface-white">
|
||||
<div key="name" class="py-1 px-2 hover:bg-surface-gray-2 rounded">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'DataImportList',
|
||||
query: {
|
||||
step: 'list',
|
||||
},
|
||||
}"
|
||||
<Popover placement="right-start" trigger="hover" class="flex w-full">
|
||||
<template #target="{ togglePopover }">
|
||||
<button
|
||||
:class="[
|
||||
'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-ink-gray-7 hover:bg-surface-gray-2',
|
||||
]"
|
||||
>
|
||||
<div class="flex flex-col items-center space-y-1">
|
||||
<ArrowDownToLine
|
||||
class="size-9 text-ink-gray-7 p-2 bg-surface-gray-2 rounded-md"
|
||||
/>
|
||||
<div class="text-sm text-ink-gray-7">
|
||||
{{ __('Import') }}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Wrench class="size-4 stroke-1.5" />
|
||||
<span class="whitespace-nowrap">
|
||||
{{ __('Configuration') }}
|
||||
</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight class="h-4 w-4 stroke-1.5" />
|
||||
</button>
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<div key="name" class="py-1 px-2 hover:bg-surface-gray-2 rounded">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'DataImportList',
|
||||
query: {
|
||||
step: 'list',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col items-center space-y-1">
|
||||
<ArrowDownToLine
|
||||
class="size-9 text-ink-gray-7 p-2 bg-surface-gray-2 rounded-md"
|
||||
/>
|
||||
<div class="text-sm text-ink-gray-7">
|
||||
{{ __('Import') }}
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ArrowDownToLine } from 'lucide-vue-next'
|
||||
import { Popover } from 'frappe-ui'
|
||||
import { ArrowDownToLine, Wrench, ChevronRight } from 'lucide-vue-next'
|
||||
</script>
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
<script setup>
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { Dropdown } from 'frappe-ui'
|
||||
import { call, Dropdown, toast } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { convertToTitleCase } from '@/utils'
|
||||
import { usersStore } from '@/stores/user'
|
||||
@@ -85,8 +85,7 @@ import {
|
||||
User,
|
||||
Settings,
|
||||
Sun,
|
||||
Wrench,
|
||||
Zap,
|
||||
Trash2,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -171,17 +170,24 @@ const userDropdownOptions = computed(() => {
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Configuration',
|
||||
icon: Wrench,
|
||||
submenu: [
|
||||
{
|
||||
component: markRaw(Configuration),
|
||||
},
|
||||
],
|
||||
component: markRaw(Configuration),
|
||||
condition: () => {
|
||||
return userResource.data?.is_moderator
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Clear Demo Data',
|
||||
icon: Trash2,
|
||||
onClick: () => {
|
||||
clearDemoDataConfirmation()
|
||||
},
|
||||
condition: () => {
|
||||
return (
|
||||
userResource.data?.is_moderator &&
|
||||
settingsStore.settings.data?.demo_data_present
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: FrappeCloudIcon,
|
||||
label: 'Login to Frappe Cloud',
|
||||
@@ -241,4 +247,36 @@ const loginToFrappeCloud = () => {
|
||||
let redirect_to = '/dashboard/sites/' + userResource.data.sitename
|
||||
window.open(`${frappeCloudBaseEndpoint}${redirect_to}`, '_blank')
|
||||
}
|
||||
|
||||
const clearDemoDataConfirmation = () => {
|
||||
$dialog({
|
||||
title: __('Confirm clearing demo data?'),
|
||||
message: __(
|
||||
'Are you sure you want to clear the demo data? This would delete the course "A guide to Frappe Learning" along with all its associated data. This action cannot be undone.'
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
label: __('Confirm'),
|
||||
theme: 'red',
|
||||
variant: 'solid',
|
||||
onClick(close) {
|
||||
clearDemoData()
|
||||
close()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const clearDemoData = () => {
|
||||
call('lms.lms.api.clear_demo_data')
|
||||
.then(() => {
|
||||
window.location.href = '/lms'
|
||||
toast.success(__('Demo data cleared successfully'))
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(__(error.message || 'Error clearing demo data'))
|
||||
console.error('Error clearing demo data:', error)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{{ __('Upcoming Evaluations') }}
|
||||
</div>
|
||||
<Button v-if="canScheduleEvals" @click="openEvalModal">
|
||||
{{ __('Schedule Evaluation') }}
|
||||
{{ __('Schedule') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
@@ -31,55 +31,38 @@
|
||||
<div v-if="upcoming_evals.data?.length">
|
||||
<div
|
||||
class="grid gap-4"
|
||||
:class="forHome ? 'grid-cols-1 md:grid-cols-2' : 'grid-cols-3'"
|
||||
:class="forHome ? 'grid-cols-1 md:grid-cols-4' : 'grid-cols-1'"
|
||||
>
|
||||
<div v-for="evl in upcoming_evals.data">
|
||||
<div class="border text-ink-gray-7 rounded-md p-3">
|
||||
<div
|
||||
class="border hover:border-outline-gray-3 text-ink-gray-7 rounded-md p-3"
|
||||
>
|
||||
<div class="flex justify-between mb-3">
|
||||
<span class="text-lg font-semibold text-ink-gray-9 leading-5">
|
||||
<span class="font-semibold text-ink-gray-9 leading-5">
|
||||
{{ evl.course_title }}
|
||||
</span>
|
||||
<Menu
|
||||
<Dropdown
|
||||
v-if="evl.date > dayjs().format()"
|
||||
as="div"
|
||||
class="relative inline-block text-left"
|
||||
:options="[
|
||||
{
|
||||
label: __('Cancel'),
|
||||
icon: Ban,
|
||||
onClick() {
|
||||
cancelEvaluation(evl)
|
||||
},
|
||||
},
|
||||
]"
|
||||
placement="left"
|
||||
side="left"
|
||||
>
|
||||
<div>
|
||||
<MenuButton class="inline-flex w-full justify-center">
|
||||
<EllipsisVertical class="w-4 h-4 stroke-1.5" />
|
||||
</MenuButton>
|
||||
</div>
|
||||
|
||||
<transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0"
|
||||
>
|
||||
<MenuItems
|
||||
class="absolute mt-2 w-32 rounded-md bg-surface-white border p-1.5"
|
||||
>
|
||||
<MenuItem v-slot="{ active }">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
@click="cancelEvaluation(evl)"
|
||||
>
|
||||
<template #prefix>
|
||||
<Ban
|
||||
:active="active"
|
||||
class="size-4 stroke-1.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
{{ __('Cancel') }}
|
||||
</Button>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</transition>
|
||||
</Menu>
|
||||
<template v-slot="{ open }">
|
||||
<Button variant="ghost">
|
||||
<template #icon>
|
||||
<EllipsisVertical class="w-4 h-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div class="flex items-center mb-2">
|
||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||
@@ -114,7 +97,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!endDateHasPassed" class="text-ink-gray-5">
|
||||
<div v-else-if="!endDateHasPassed" class="text-ink-gray-7">
|
||||
{{ __('Schedule an evaluation to get certified.') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,11 +120,11 @@ import {
|
||||
} from 'lucide-vue-next'
|
||||
import { inject, ref, getCurrentInstance, computed } from 'vue'
|
||||
import { formatTime } from '@/utils'
|
||||
import { Button, createResource, call } from 'frappe-ui'
|
||||
import { Button, createListResource, call, Dropdown, toast } from 'frappe-ui'
|
||||
import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
|
||||
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
const user = inject('$user')
|
||||
const showEvalModal = ref(false)
|
||||
const app = getCurrentInstance()
|
||||
const { $dialog } = app.appContext.config.globalProperties
|
||||
@@ -165,12 +148,28 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const upcoming_evals = createResource({
|
||||
url: 'lms.lms.utils.get_upcoming_evals',
|
||||
params: {
|
||||
courses: props.courses.map((course) => course.course),
|
||||
batch: props.batch,
|
||||
const upcoming_evals = createListResource({
|
||||
doctype: 'LMS Certificate Request',
|
||||
filters: {
|
||||
course: props.courses?.length
|
||||
? ['in', props.courses.map((course) => course.course)]
|
||||
: undefined,
|
||||
batch_name: props.batch || undefined,
|
||||
status: 'Upcoming',
|
||||
member: user?.data?.name,
|
||||
date: ['>=', dayjs().format('YYYY-MM-DD')],
|
||||
},
|
||||
fields: [
|
||||
'name',
|
||||
'date',
|
||||
'start_time',
|
||||
'evaluator_name',
|
||||
'course_title',
|
||||
'member',
|
||||
'member_name',
|
||||
'google_meet_link',
|
||||
],
|
||||
orderBy: 'date',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
@@ -184,7 +183,7 @@ const openEvalCall = (evl) => {
|
||||
|
||||
const evaluationCourses = computed(() => {
|
||||
return props.courses.filter((course) => {
|
||||
return course.evaluator != ''
|
||||
return course.evaluator && course.evaluator != ''
|
||||
})
|
||||
})
|
||||
|
||||
@@ -202,7 +201,7 @@ const endDateHasPassed = computed(() => {
|
||||
|
||||
const cancelEvaluation = (evl) => {
|
||||
$dialog({
|
||||
title: __('Cancel this evaluation?'),
|
||||
title: __('Confirm Cancellation?'),
|
||||
message: __(
|
||||
'Are you sure you want to cancel this evaluation? This action cannot be undone.'
|
||||
),
|
||||
@@ -212,11 +211,15 @@ const cancelEvaluation = (evl) => {
|
||||
theme: 'red',
|
||||
variant: 'solid',
|
||||
onClick(close) {
|
||||
call('lms.lms.api.cancel_evaluation', { evaluation: evl }).then(
|
||||
() => {
|
||||
call('lms.lms.api.cancel_evaluation', { evaluation: evl })
|
||||
.then(() => {
|
||||
upcoming_evals.reload()
|
||||
}
|
||||
)
|
||||
toast.success(__('Evaluation cancelled successfully'))
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(__(err.messages?.[0] || err))
|
||||
console.error(err)
|
||||
})
|
||||
close()
|
||||
},
|
||||
},
|
||||
|
||||
@@ -89,6 +89,11 @@
|
||||
<span class="text-sm font-medium">
|
||||
{{ formatSeconds(currentTime) }} / {{ formatSeconds(duration) }}
|
||||
</span>
|
||||
|
||||
<Dropdown :options="dropdownOptions">
|
||||
<Button>{{ playbackSpeedLabel }}</Button>
|
||||
</Dropdown>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="toggleMute"
|
||||
@@ -151,9 +156,9 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { ref, onMounted, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
|
||||
import { Button, Dialog } from 'frappe-ui'
|
||||
import { Button, Dialog, Dropdown } from 'frappe-ui'
|
||||
import { formatSeconds, formatTimestamp } from '@/utils'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import Play from '@/components/Icons/Play.vue'
|
||||
@@ -173,6 +178,16 @@ const currentQuiz = ref(null)
|
||||
const nextQuiz = ref({})
|
||||
const { settings } = useSettings()
|
||||
|
||||
// Speed control states
|
||||
const playbackSpeed = ref(1)
|
||||
const playbackSpeedLabel = ref('1x')
|
||||
const playbackSpeeds = [
|
||||
{ label: '0.5x', value: 0.5 },
|
||||
{ label: '1x', value: 1 },
|
||||
{ label: '1.5x', value: 1.5 },
|
||||
{ label: '2x', value: 2 },
|
||||
]
|
||||
|
||||
const props = defineProps({
|
||||
file: {
|
||||
type: String,
|
||||
@@ -199,6 +214,9 @@ const props = defineProps({
|
||||
onMounted(() => {
|
||||
updateCurrentTime()
|
||||
updateNextQuiz()
|
||||
if (videoRef.value) {
|
||||
videoRef.value.playbackRate = 1
|
||||
}
|
||||
})
|
||||
|
||||
const updateCurrentTime = () => {
|
||||
@@ -321,6 +339,22 @@ const getQuizMarkerStyle = (time) => {
|
||||
left: `${percentage}%`,
|
||||
}
|
||||
}
|
||||
|
||||
const setPlaybackSpeed = (speed, label) => {
|
||||
playbackSpeed.value = speed
|
||||
playbackSpeedLabel.value = label
|
||||
if (videoRef.value) {
|
||||
videoRef.value.playbackRate = speed
|
||||
}
|
||||
}
|
||||
|
||||
const dropdownOptions = computed(() =>
|
||||
playbackSpeeds.map((speed) => ({
|
||||
label: speed.label,
|
||||
active: playbackSpeed.value === speed.value,
|
||||
onClick: () => setPlaybackSpeed(speed.value, speed.label),
|
||||
}))
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -59,7 +59,7 @@ onMounted(() => {
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
label: 'Submissions',
|
||||
label: __('Submissions'),
|
||||
route: { name: 'AssignmentSubmissionList' },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -140,9 +140,6 @@ const assignmentFilter = computed(() => {
|
||||
if (typeFilter.value) {
|
||||
filters.type = typeFilter.value
|
||||
}
|
||||
if (!user.data?.is_moderator) {
|
||||
filters.owner = user.data?.email
|
||||
}
|
||||
return filters
|
||||
})
|
||||
|
||||
@@ -203,7 +200,7 @@ const assignmentTypes = computed(() => {
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{
|
||||
label: 'Assignments',
|
||||
label: __('Assignments'),
|
||||
route: { name: 'Assignments' },
|
||||
},
|
||||
])
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
<template>
|
||||
<div v-if="badge.data">
|
||||
<div class="p-5 flex flex-col items-center mt-40">
|
||||
<div class="text-3xl font-semibold">
|
||||
{{ badge.data.badge }}
|
||||
</div>
|
||||
<img
|
||||
:src="badge.data.badge_image"
|
||||
:alt="badge.data.badge"
|
||||
class="h-60 mt-2"
|
||||
/>
|
||||
<div class="">
|
||||
{{
|
||||
__('This badge has been awarded to {0} on {1}.').format(
|
||||
badge.data.member_name,
|
||||
dayjs(badge.data.issued_on).format('DD MMM YYYY')
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
{{ badge.data.badge_description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, usePageMeta } from 'frappe-ui'
|
||||
import { computed, inject } from 'vue'
|
||||
import { sessionStore } from '../stores/session'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
const { brand } = sessionStore()
|
||||
|
||||
const props = defineProps({
|
||||
badgeName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const badge = createResource({
|
||||
url: 'frappe.client.get',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Badge Assignment',
|
||||
filters: {
|
||||
badge: props.badgeName,
|
||||
member: props.email,
|
||||
},
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: 'Badges',
|
||||
},
|
||||
{
|
||||
label: badge.data.badge,
|
||||
route: {
|
||||
name: 'Badge',
|
||||
params: {
|
||||
badge: badge.data.badge,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: badge.data.badge,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,395 +0,0 @@
|
||||
<template>
|
||||
<div v-if="isAdmin || isStudent" class="">
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
v-if="isAdmin && batch.data?.certification"
|
||||
@click="openCertificateDialog = true"
|
||||
>
|
||||
{{ __('Generate Certificates') }}
|
||||
</Button>
|
||||
<Button v-if="canMakeAnnouncement()" @click="openAnnouncementModal()">
|
||||
<span>
|
||||
{{ __('Make an Announcement') }}
|
||||
</span>
|
||||
<template #suffix>
|
||||
<SendIcon class="h-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
v-if="batch.data"
|
||||
class="grid grid-cols-1 md:grid-cols-[75%,25%] h-[calc(100vh-3.2rem)]"
|
||||
>
|
||||
<div class="border-r">
|
||||
<Tabs
|
||||
v-model="tabIndex"
|
||||
as="div"
|
||||
:tabs="tabs"
|
||||
tablistClass="overflow-y-hidden bg-surface-white"
|
||||
>
|
||||
<template #tab="{ tab, selected }" class="overflow-x-hidden">
|
||||
<div>
|
||||
<button
|
||||
class="group -mb-px flex items-center gap-1 border-b border-transparent py-2.5 text-base text-ink-gray-5 duration-300 ease-in-out hover:border-outline-gray-3 hover:text-ink-gray-9"
|
||||
:class="{ 'text-ink-gray-9': selected }"
|
||||
>
|
||||
<component
|
||||
v-if="tab.icon"
|
||||
:is="tab.icon"
|
||||
class="h-4 stroke-1.5"
|
||||
/>
|
||||
{{ __(tab.label) }}
|
||||
<Badge
|
||||
v-if="tab.count"
|
||||
:class="{
|
||||
'text-ink-gray-9 border border-gray-900': selected,
|
||||
}"
|
||||
variant="subtle"
|
||||
theme="gray"
|
||||
size="sm"
|
||||
>
|
||||
{{ tab.count }}
|
||||
</Badge>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template #tab-panel="{ tab }">
|
||||
<div class="pt-5 px-5 pb-10">
|
||||
<div v-if="tab.label == 'Courses'">
|
||||
<BatchCourses :batch="batch.data.name" />
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Dashboard' && isStudent">
|
||||
<BatchDashboard :batch="batch" :isStudent="isStudent" />
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Dashboard'">
|
||||
<AdminBatchDashboard :batch="batch" />
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Students'">
|
||||
<BatchStudents :batch="batch" />
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Classes'">
|
||||
<LiveClass
|
||||
:batch="batch.data.name"
|
||||
:zoomAccount="batch.data.zoom_account"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Assessments'">
|
||||
<Assessments :batch="batch.data.name" />
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Announcements'">
|
||||
<Announcements :batch="batch.data.name" />
|
||||
</div>
|
||||
<div v-else-if="tab.label == 'Discussions'">
|
||||
<Discussions
|
||||
doctype="LMS Batch"
|
||||
:docname="batch.data.name"
|
||||
:title="__('Discussions')"
|
||||
:key="batch.data.name"
|
||||
:singleThread="true"
|
||||
:scrollToBottom="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div class="p-5 border-t md:border-t-0">
|
||||
<div class="mb-10">
|
||||
<div class="text-ink-gray-7 font-semibold mb-2">
|
||||
{{ __('About this batch') }}
|
||||
</div>
|
||||
<div
|
||||
v-html="batch.data.description"
|
||||
class="leading-5 mb-4 text-ink-gray-7"
|
||||
></div>
|
||||
|
||||
<div class="flex items-center avatar-group overlap mb-5">
|
||||
<div
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': batch.data.instructors.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in batch.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</div>
|
||||
<CourseInstructors :instructors="batch.data.instructors" />
|
||||
</div>
|
||||
<DateRange
|
||||
:startDate="batch.data.start_date"
|
||||
:endDate="batch.data.end_date"
|
||||
class="mb-3"
|
||||
/>
|
||||
<div class="flex items-center mb-3 text-ink-gray-7">
|
||||
<Clock class="h-4 w-4 stroke-1.5 mr-2" />
|
||||
<span>
|
||||
{{ formatTime(batch.data.start_time) }} -
|
||||
{{ formatTime(batch.data.end_time) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="batch.data.timezone"
|
||||
class="flex items-center mb-3 text-ink-gray-7"
|
||||
>
|
||||
<Globe class="h-4 w-4 stroke-1.5 mr-2" />
|
||||
<span>
|
||||
{{ batch.data.timezone }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="dayjs().isSameOrAfter(dayjs(batch.data.start_date))">
|
||||
<div class="text-ink-gray-7 font-semibold mb-2">
|
||||
{{ __('Feedback') }}
|
||||
</div>
|
||||
<BatchFeedback :batch="batch.data?.name" />
|
||||
</div>
|
||||
</div>
|
||||
<AnnouncementModal
|
||||
v-model="showAnnouncementModal"
|
||||
:batch="batch.data.name"
|
||||
:students="batch.data.students"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!user.data?.name" class="">
|
||||
<div class="text-base border rounded-md w-1/3 mx-auto my-32">
|
||||
<div class="border-b px-5 py-3 font-medium">
|
||||
<span
|
||||
class="inline-flex items-center before:bg-surface-red-5 before:w-2 before:h-2 before:rounded-md before:mr-2"
|
||||
></span>
|
||||
{{ __('Not Permitted') }}
|
||||
</div>
|
||||
<div class="px-5 py-3">
|
||||
<div v-if="user.data" class="mb-4 leading-6">
|
||||
{{
|
||||
__(
|
||||
'You are not a member of this batch. Please checkout our upcoming batches.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div v-else class="mb-4 leading-6">
|
||||
{{ __('Please login to access this page.') }}
|
||||
</div>
|
||||
<router-link
|
||||
v-if="user.data"
|
||||
:to="{
|
||||
name: 'Batches',
|
||||
params: {
|
||||
batchName: batch.data?.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" class="w-full">
|
||||
{{ __('Upcoming Batches') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button
|
||||
v-else
|
||||
variant="solid"
|
||||
class="w-full"
|
||||
@click="redirectToLogin()"
|
||||
>
|
||||
{{ __('Login') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BulkCertificates
|
||||
v-if="batch.data"
|
||||
v-model="openCertificateDialog"
|
||||
:batch="batch.data"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, inject, ref, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
createResource,
|
||||
Tabs,
|
||||
Badge,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import {
|
||||
Clock,
|
||||
LayoutDashboard,
|
||||
BookOpen,
|
||||
Laptop,
|
||||
BookOpenCheck,
|
||||
Mail,
|
||||
SendIcon,
|
||||
MessageCircle,
|
||||
Globe,
|
||||
ClipboardPen,
|
||||
} from 'lucide-vue-next'
|
||||
import { formatTime } from '@/utils'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import BatchDashboard from '@/components/BatchDashboard.vue'
|
||||
import BatchCourses from '@/components/BatchCourses.vue'
|
||||
import LiveClass from '@/components/LiveClass.vue'
|
||||
import BatchStudents from '@/components/BatchStudents.vue'
|
||||
import AdminBatchDashboard from '@/components/AdminBatchDashboard.vue'
|
||||
import Assessments from '@/components/Assessments.vue'
|
||||
import Announcements from '@/components/Annoucements.vue'
|
||||
import AnnouncementModal from '@/components/Modals/AnnouncementModal.vue'
|
||||
import Discussions from '@/components/Discussions.vue'
|
||||
import DateRange from '@/components/Common/DateRange.vue'
|
||||
import BulkCertificates from '@/components/Modals/BulkCertificates.vue'
|
||||
import BatchFeedback from '@/components/BatchFeedback.vue'
|
||||
import dayjs from 'dayjs/esm'
|
||||
import { getLmsRoute } from '@/utils/basePath'
|
||||
|
||||
const user = inject('$user')
|
||||
const showAnnouncementModal = ref(false)
|
||||
const openCertificateDialog = ref(false)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { brand } = sessionStore()
|
||||
const tabIndex = ref(0)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const tabs = computed(() => {
|
||||
let batchTabs = []
|
||||
batchTabs.push({
|
||||
label: 'Dashboard',
|
||||
icon: LayoutDashboard,
|
||||
})
|
||||
|
||||
if (isAdmin.value) {
|
||||
batchTabs.push({
|
||||
label: 'Students',
|
||||
icon: ClipboardPen,
|
||||
})
|
||||
}
|
||||
|
||||
batchTabs.push({
|
||||
label: 'Courses',
|
||||
icon: BookOpen,
|
||||
})
|
||||
|
||||
batchTabs.push({
|
||||
label: 'Classes',
|
||||
icon: Laptop,
|
||||
})
|
||||
|
||||
if (isAdmin.value) {
|
||||
batchTabs.push({
|
||||
label: 'Assessments',
|
||||
icon: BookOpenCheck,
|
||||
})
|
||||
}
|
||||
|
||||
batchTabs.push({
|
||||
label: 'Announcements',
|
||||
icon: Mail,
|
||||
})
|
||||
|
||||
batchTabs.push({
|
||||
label: 'Discussions',
|
||||
icon: MessageCircle,
|
||||
})
|
||||
return batchTabs
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
batchName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const hash = route.hash
|
||||
if (hash) {
|
||||
tabs.value.forEach((tab, index) => {
|
||||
if (tab.label?.toLowerCase() === hash.replace('#', '')) {
|
||||
tabIndex.value = index
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const batch = createResource({
|
||||
url: 'lms.lms.utils.get_batch_details',
|
||||
cache: ['batch', props.batchName],
|
||||
params: {
|
||||
batch: props.batchName,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [{ label: 'Batches', route: { name: 'Batches' } }]
|
||||
if (!isStudent.value) {
|
||||
crumbs.push({
|
||||
label: 'Details',
|
||||
route: {
|
||||
name: 'BatchDetail',
|
||||
params: {
|
||||
batchName: batch.data?.name,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
crumbs.push({
|
||||
label: batch?.data?.title,
|
||||
route: { name: 'Batch', params: { batchName: props.batchName } },
|
||||
})
|
||||
return crumbs
|
||||
})
|
||||
|
||||
const isStudent = computed(() => {
|
||||
return (
|
||||
user?.data &&
|
||||
batch.data?.students?.length &&
|
||||
batch.data?.students.includes(user.data.name)
|
||||
)
|
||||
})
|
||||
|
||||
const redirectToLogin = () => {
|
||||
window.location.href = `/login?redirect-to=${getLmsRoute(
|
||||
`batches/${props.batchName}`
|
||||
)}`
|
||||
}
|
||||
|
||||
const openAnnouncementModal = () => {
|
||||
showAnnouncementModal.value = true
|
||||
}
|
||||
|
||||
watch(tabIndex, () => {
|
||||
const tab = tabs.value[tabIndex.value]
|
||||
if (tab.label != route.hash.replace('#', '')) {
|
||||
router.push({ ...route, hash: `#${tab.label.toLowerCase()}` })
|
||||
}
|
||||
})
|
||||
|
||||
const canMakeAnnouncement = () => {
|
||||
if (readOnlyMode) return false
|
||||
|
||||
if (!batch.data?.students?.length) return false
|
||||
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
}
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: batch?.data?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,158 +0,0 @@
|
||||
<template>
|
||||
<div v-if="batch.data" class="">
|
||||
<header
|
||||
class="sticky top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
</header>
|
||||
<div class="m-5 pb-10">
|
||||
<div class="flex justify-between w-full">
|
||||
<div class="md:w-2/3">
|
||||
<div class="text-3xl font-semibold text-ink-gray-9">
|
||||
{{ batch.data.title }}
|
||||
</div>
|
||||
<div class="my-3 leading-6 text-ink-gray-7">
|
||||
{{ batch.data.description }}
|
||||
</div>
|
||||
<div class="flex avatar-group overlap">
|
||||
<div
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': batch.data.instructors.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in batch.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</div>
|
||||
<CourseInstructors :instructors="batch.data.instructors" />
|
||||
</div>
|
||||
<BatchOverlay :batch="batch" class="md:hidden mt-5" />
|
||||
<div
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-10"
|
||||
v-html="batch.data.batch_details"
|
||||
></div>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<BatchOverlay :batch="batch" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="batch.data.courses.length">
|
||||
<div class="flex items-center mt-10">
|
||||
<div class="text-2xl font-semibold text-ink-gray-9">
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mt-5">
|
||||
<div
|
||||
v-if="batch.data.courses"
|
||||
v-for="course in courses.data"
|
||||
:key="course.course"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'CourseDetail',
|
||||
params: {
|
||||
courseName: course.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<CourseCard :course="course" :key="course.name" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="batch.data.batch_details_raw">
|
||||
<div
|
||||
v-html="batch.data.batch_details_raw"
|
||||
class="batch-description"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, inject } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, Clock } from 'lucide-vue-next'
|
||||
import { formatTime } from '@/utils'
|
||||
import { Breadcrumbs, createResource, usePageMeta } from 'frappe-ui'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import BatchOverlay from '@/components/BatchOverlay.vue'
|
||||
import DateRange from '../components/Common/DateRange.vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
const { brand } = sessionStore()
|
||||
|
||||
const props = defineProps({
|
||||
batchName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const batch = createResource({
|
||||
url: 'lms.lms.utils.get_batch_details',
|
||||
cache: ['batch', props.batchName],
|
||||
params: {
|
||||
batch: props.batchName,
|
||||
},
|
||||
auto: true,
|
||||
onSuccess: (data) => {
|
||||
if (!data) {
|
||||
router.push({ name: 'Batches' })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const courses = createResource({
|
||||
url: 'lms.lms.utils.get_batch_courses',
|
||||
params: {
|
||||
batch: props.batchName,
|
||||
},
|
||||
cache: ['batchCourses', props.batchName],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let items = [{ label: 'Batches', route: { name: 'Batches' } }]
|
||||
items.push({
|
||||
label: batch?.data?.title,
|
||||
route: { name: 'BatchDetail', params: { batchName: batch?.data?.name } },
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: batch?.data?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.batch-description p {
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.batch-description li {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.batch-description ol {
|
||||
list-style: auto;
|
||||
margin: revert;
|
||||
padding: revert;
|
||||
}
|
||||
|
||||
.batch-description strong {
|
||||
font-weight: 600;
|
||||
color: theme('colors.gray.900') !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,592 +0,0 @@
|
||||
<template>
|
||||
<div class="">
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button v-if="batchDetail.data?.name" @click="deleteBatch">
|
||||
<template #icon>
|
||||
<Trash2 class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="solid" @click="saveBatch()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="py-5">
|
||||
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Details') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batch.title"
|
||||
:label="__('Title')"
|
||||
:required="true"
|
||||
class="w-full"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="instructors"
|
||||
doctype="Course Evaluator"
|
||||
:label="__('Instructors')"
|
||||
:required="true"
|
||||
:onCreate="(close) => openSettings('Evaluators', close)"
|
||||
:filters="{ ignore_user_type: 1 }"
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="batch.description"
|
||||
:label="__('Short Description')"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
:placeholder="__('Short description of the batch')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
<FormControl
|
||||
v-model="batch.published"
|
||||
type="checkbox"
|
||||
:label="__('Published')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.allow_self_enrollment"
|
||||
type="checkbox"
|
||||
:label="__('Allow self enrollment')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.certification"
|
||||
type="checkbox"
|
||||
:label="__('Certification')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Date and Time') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-10">
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batch.start_date"
|
||||
:label="__('Batch Start Date')"
|
||||
type="date"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.end_date"
|
||||
:label="__('Batch End Date')"
|
||||
type="date"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batch.start_time"
|
||||
:label="__('Session Start Time')"
|
||||
type="time"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.end_time"
|
||||
:label="__('Session End Time')"
|
||||
type="time"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batch.timezone"
|
||||
:label="__('Timezone')"
|
||||
type="text"
|
||||
:placeholder="__('Example: IST (+5:30)')"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.evaluation_end_date"
|
||||
:label="__('Evaluation End Date')"
|
||||
type="date"
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
|
||||
<div>
|
||||
<label class="block text-sm text-ink-gray-5 mb-1">
|
||||
{{ __('Batch Details') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</label>
|
||||
<TextEditor
|
||||
:content="batch.batch_details"
|
||||
@change="(val) => (batch.batch_details = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[20rem] overflow-y-scroll mb-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Configurations') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-10">
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batch.seat_count"
|
||||
:label="__('Seat Count')"
|
||||
type="number"
|
||||
class="mb-4"
|
||||
:placeholder="__('Number of seats available')"
|
||||
/>
|
||||
<Link
|
||||
doctype="Email Template"
|
||||
:label="__('Email Template')"
|
||||
v-model="batch.confirmation_email_template"
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
openSettings('Email Templates', close)
|
||||
}
|
||||
"
|
||||
/>
|
||||
<Link
|
||||
doctype="LMS Zoom Settings"
|
||||
:label="__('Zoom Account')"
|
||||
v-model="batch.zoom_account"
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
openSettings('Zoom Accounts', close)
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batch.medium"
|
||||
type="select"
|
||||
:options="[
|
||||
{
|
||||
label: 'Online',
|
||||
value: 'Online',
|
||||
},
|
||||
{
|
||||
label: 'Offline',
|
||||
value: 'Offline',
|
||||
},
|
||||
]"
|
||||
:label="__('Medium')"
|
||||
class="mb-4"
|
||||
/>
|
||||
<Link
|
||||
doctype="LMS Category"
|
||||
:label="__('Category')"
|
||||
v-model="batch.category"
|
||||
:onCreate="(value, close) => openSettings('Categories', close)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<Uploader
|
||||
v-model="batch.video_link"
|
||||
:label="__('Preview Video')"
|
||||
type="video"
|
||||
:required="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-20 pb-5 space-y-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Pricing') }}
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="batch.paid_batch"
|
||||
type="checkbox"
|
||||
:label="__('Paid Batch')"
|
||||
/>
|
||||
<div
|
||||
v-if="batch.paid_batch"
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-5"
|
||||
>
|
||||
<FormControl
|
||||
v-model="batch.amount"
|
||||
:label="__('Amount')"
|
||||
type="number"
|
||||
/>
|
||||
<Link
|
||||
doctype="Currency"
|
||||
v-model="batch.currency"
|
||||
:filters="{ enabled: 1 }"
|
||||
:label="__('Currency')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 md:px-20 pb-5 space-y-5 border-b">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Meta Tags') }}
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<Uploader
|
||||
v-model="batch.meta_image"
|
||||
:label="__('Meta Image')"
|
||||
type="image"
|
||||
:required="false"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="meta.description"
|
||||
:label="__('Meta Description')"
|
||||
type="textarea"
|
||||
:rows="7"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="meta.keywords"
|
||||
:label="__('Meta Keywords')"
|
||||
type="textarea"
|
||||
:rows="7"
|
||||
:placeholder="__('Comma separated keywords for SEO')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
computed,
|
||||
getCurrentInstance,
|
||||
inject,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
reactive,
|
||||
ref,
|
||||
} from 'vue'
|
||||
import {
|
||||
Breadcrumbs,
|
||||
FormControl,
|
||||
Button,
|
||||
TextEditor,
|
||||
createResource,
|
||||
usePageMeta,
|
||||
toast,
|
||||
call,
|
||||
} from 'frappe-ui'
|
||||
import {
|
||||
escapeHTML,
|
||||
getMetaInfo,
|
||||
openSettings,
|
||||
sanitizeHTML,
|
||||
updateMetaInfo,
|
||||
} from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Trash2 } from 'lucide-vue-next'
|
||||
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import Uploader from '@/components/Controls/Uploader.vue'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
const { brand } = sessionStore()
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
const instructors = ref([])
|
||||
const app = getCurrentInstance()
|
||||
const { capture } = useTelemetry()
|
||||
const { $dialog } = app.appContext.config.globalProperties
|
||||
|
||||
const props = defineProps({
|
||||
batchName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const batch = reactive({
|
||||
title: '',
|
||||
published: false,
|
||||
description: '',
|
||||
batch_details: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
timezone: '',
|
||||
evaluation_end_date: '',
|
||||
confirmation_email_template: '',
|
||||
seat_count: '',
|
||||
medium: '',
|
||||
category: '',
|
||||
allow_self_enrollment: false,
|
||||
certification: false,
|
||||
meta_image: null,
|
||||
paid_batch: false,
|
||||
currency: '',
|
||||
amount: 0,
|
||||
zoom_account: '',
|
||||
video_link: '',
|
||||
})
|
||||
|
||||
const meta = reactive({
|
||||
description: '',
|
||||
keywords: '',
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data) window.location.href = '/login'
|
||||
if (props.batchName != 'new') {
|
||||
fetchBatchInfo()
|
||||
} else {
|
||||
capture('batch_form_opened')
|
||||
}
|
||||
window.addEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const fetchBatchInfo = () => {
|
||||
batchDetail.reload()
|
||||
getMetaInfo('batches', props.batchName, meta)
|
||||
}
|
||||
|
||||
const keyboardShortcut = (e) => {
|
||||
if (
|
||||
e.key === 's' &&
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
!e.target.classList.contains('ProseMirror')
|
||||
) {
|
||||
saveBatch()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const newBatch = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Batch',
|
||||
meta_image: batch.image,
|
||||
video_link: batch.video_link,
|
||||
instructors: instructors.value.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
...batch,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const batchDetail = createResource({
|
||||
url: 'frappe.client.get',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Batch',
|
||||
name: props.batchName,
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
updateBatchData(data)
|
||||
},
|
||||
})
|
||||
|
||||
const updateBatchData = (data) => {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (key == 'instructors') {
|
||||
data.instructors.forEach((instructor) => {
|
||||
instructors.value.push(instructor.instructor)
|
||||
})
|
||||
} else if (['start_time', 'end_time'].includes(key)) {
|
||||
batch[key] = formatTime(data[key])
|
||||
} else if (Object.hasOwn(batch, key)) batch[key] = data[key]
|
||||
})
|
||||
let checkboxes = [
|
||||
'published',
|
||||
'paid_batch',
|
||||
'allow_self_enrollment',
|
||||
'certification',
|
||||
]
|
||||
for (let idx in checkboxes) {
|
||||
let key = checkboxes[idx]
|
||||
batch[key] = batch[key] ? true : false
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (timeStr) => {
|
||||
let [hours, minutes, seconds] = timeStr.split(':')
|
||||
hours = hours.length == 1 ? '0' + hours : hours
|
||||
return `${hours}:${minutes}`
|
||||
}
|
||||
|
||||
const editBatch = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Batch',
|
||||
name: props.batchName,
|
||||
fieldname: {
|
||||
meta_image: batch.meta_image,
|
||||
video_link: batch.video_link,
|
||||
instructors: instructors.value.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
...batch,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const validateFields = () => {
|
||||
batch.description = sanitizeHTML(batch.description)
|
||||
batch.batch_details = sanitizeHTML(batch.batch_details)
|
||||
|
||||
Object.keys(batch).forEach((key) => {
|
||||
if (
|
||||
!['description', 'batch_details'].includes(key) &&
|
||||
typeof batch[key] === 'string'
|
||||
) {
|
||||
batch[key] = escapeHTML(batch[key])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const saveBatch = () => {
|
||||
validateFields()
|
||||
if (batchDetail.data) {
|
||||
editBatchDetails()
|
||||
} else {
|
||||
createNewBatch()
|
||||
}
|
||||
}
|
||||
|
||||
const createNewBatch = () => {
|
||||
newBatch.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
if (user.data?.is_system_manager) {
|
||||
updateOnboardingStep('create_first_batch', true, false, () => {
|
||||
localStorage.setItem('firstBatch', data.name)
|
||||
})
|
||||
}
|
||||
updateMetaInfo('batches', data.name, meta)
|
||||
capture('batch_created')
|
||||
router.push({
|
||||
name: 'BatchDetail',
|
||||
params: {
|
||||
batchName: data.name,
|
||||
},
|
||||
})
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const editBatchDetails = () => {
|
||||
editBatch.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
updateMetaInfo('batches', data.name, meta)
|
||||
router.push({
|
||||
name: 'BatchDetail',
|
||||
params: {
|
||||
batchName: data.name,
|
||||
},
|
||||
})
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const deleteBatch = () => {
|
||||
$dialog({
|
||||
title: __('Confirm your action to delete'),
|
||||
message: __(
|
||||
'Deleting this batch will also delete all its data including enrolled students, linked courses, assessments, feedback and discussions. Are you sure you want to continue?'
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
label: __('Delete'),
|
||||
theme: 'red',
|
||||
variant: 'solid',
|
||||
onClick({ close }) {
|
||||
trashBatch(close)
|
||||
close()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const trashBatch = (close) => {
|
||||
call('lms.lms.api.delete_batch', {
|
||||
batch: props.batchName,
|
||||
}).then(() => {
|
||||
toast.success(__('Batch deleted successfully'))
|
||||
close()
|
||||
router.push({
|
||||
name: 'Batches',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [
|
||||
{
|
||||
label: 'Batches',
|
||||
route: {
|
||||
name: 'Batches',
|
||||
},
|
||||
},
|
||||
]
|
||||
if (batchDetail.data) {
|
||||
crumbs.push({
|
||||
label: batchDetail.data.title,
|
||||
route: {
|
||||
name: 'BatchDetail',
|
||||
params: {
|
||||
batchName: props.batchName,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
crumbs.push({
|
||||
label: props.batchName == 'new' ? 'New Batch' : 'Edit Batch',
|
||||
route: { name: 'BatchForm', params: { batchName: props.batchName } },
|
||||
})
|
||||
return crumbs
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: props.batchName == 'new' ? 'New Batch' : batchDetail.data?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,270 @@
|
||||
<template>
|
||||
<div v-if="batch.data" class="">
|
||||
<header
|
||||
class="sticky top-0 z-10 border-b flex items-center justify-between bg-surface-white px-3 py-2.5 sm:px-5"
|
||||
>
|
||||
<Breadcrumbs :items="breadcrumbs" />
|
||||
<div v-if="tabIndex == 5 && isAdmin" class="flex items-center space-x-2">
|
||||
<Badge v-if="childRef?.isDirty" theme="orange">
|
||||
{{ __('Not Saved') }}
|
||||
</Badge>
|
||||
<Button @click="childRef.deleteBatch()">
|
||||
<template #icon>
|
||||
<Trash2 class="w-4 h-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="solid" @click="childRef.submitBatch()">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
<Dropdown
|
||||
v-else-if="isAdmin && batchMenu.length"
|
||||
:options="batchMenu"
|
||||
placement="left"
|
||||
side="left"
|
||||
>
|
||||
<template v-slot="{ open }">
|
||||
<Button variant="ghost">
|
||||
<template #icon>
|
||||
<EllipsisVertical class="w-4 h-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</header>
|
||||
<div>
|
||||
<BatchOverview v-if="!isAdmin && !isStudent" :batch="batch" />
|
||||
<div v-else>
|
||||
<Tabs :tabs="tabs" v-model="tabIndex">
|
||||
<template #tab-panel="{ tab }">
|
||||
<div
|
||||
v-if="tab.label == 'Discussions'"
|
||||
class="w-[90%] lg:w-[75%] mx-auto mt-5"
|
||||
>
|
||||
<Discussions
|
||||
doctype="LMS Batch"
|
||||
:docname="batch.data.name"
|
||||
:title="__('Discussions')"
|
||||
:key="batch.data.name"
|
||||
:singleThread="true"
|
||||
:scrollToBottom="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<component
|
||||
v-else
|
||||
:is="tab.component"
|
||||
:batch="batch"
|
||||
ref="childRef"
|
||||
/>
|
||||
</template>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BulkCertificates
|
||||
v-if="batch.data"
|
||||
v-model="openCertificateDialog"
|
||||
:batch="batch.data"
|
||||
/>
|
||||
<AnnouncementModal
|
||||
v-if="showAnnouncementModal"
|
||||
v-model="showAnnouncementModal"
|
||||
:batch="batch.data.name"
|
||||
:students="batch.data.students"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
ClipboardPen,
|
||||
EllipsisVertical,
|
||||
Laptop,
|
||||
List,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
SendIcon,
|
||||
Settings2,
|
||||
Trash2,
|
||||
TrendingUp,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, inject, markRaw, ref, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import {
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
createResource,
|
||||
Dropdown,
|
||||
Tabs,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import AdminBatchDashboard from '@/pages/Batches/components/AdminBatchDashboard.vue'
|
||||
import StudentBatchDashboard from '@/pages/Batches/components/BatchDashboard.vue'
|
||||
import BatchOverview from '@/pages/Batches/BatchOverview.vue'
|
||||
import LiveClass from '@/pages/Batches/components/LiveClass.vue'
|
||||
import Announcements from '@/pages/Batches/components/Announcements.vue'
|
||||
import AnnouncementModal from '@/pages/Batches/components/AnnouncementModal.vue'
|
||||
import BatchForm from '@/pages/Batches/BatchForm.vue'
|
||||
import BulkCertificates from '@/pages/Batches/components/BulkCertificates.vue'
|
||||
import Discussions from '@/components/Discussions.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { brand } = sessionStore()
|
||||
const user = inject('$user')
|
||||
const childRef = ref(null)
|
||||
const tabIndex = ref(0)
|
||||
const tabs = ref([])
|
||||
const openCertificateDialog = ref(false)
|
||||
const showAnnouncementModal = ref(false)
|
||||
const readOnlyMode = window.read_only_mode
|
||||
|
||||
const props = defineProps({
|
||||
batchName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const updateTabIndex = () => {
|
||||
const hash = route.hash
|
||||
if (hash) {
|
||||
tabs.value.forEach((tab, index) => {
|
||||
if (tab.label?.toLowerCase() === hash.replace('#', '')) {
|
||||
tabIndex.value = index
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch(tabIndex, () => {
|
||||
const tab = tabs.value[tabIndex.value]
|
||||
if (tab.label != route.hash.replace('#', '')) {
|
||||
router.push({ ...route, hash: `#${tab.label.toLowerCase()}` })
|
||||
}
|
||||
})
|
||||
|
||||
const batch = createResource({
|
||||
url: 'lms.lms.utils.get_batch_details',
|
||||
cache: ['batch', props.batchName],
|
||||
params: {
|
||||
batch: props.batchName,
|
||||
},
|
||||
auto: true,
|
||||
onSuccess: (data) => {
|
||||
if (!data) {
|
||||
router.push({ name: 'Batches' })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
watch(batch, () => {
|
||||
updateTabs()
|
||||
updateTabIndex()
|
||||
})
|
||||
|
||||
const updateTabs = () => {
|
||||
addToTabs('Overview', markRaw(BatchOverview), List)
|
||||
if (!user.data) return
|
||||
if (isAdmin.value) {
|
||||
addToTabs('Dashboard', markRaw(AdminBatchDashboard), TrendingUp)
|
||||
} else if (isStudent.value) {
|
||||
addToTabs('Dashboard', markRaw(StudentBatchDashboard), ClipboardPen)
|
||||
}
|
||||
addToTabs('Classes', markRaw(LiveClass), Laptop)
|
||||
addToTabs('Announcements', markRaw(Announcements), Mail)
|
||||
addToTabs('Discussions', markRaw(Discussions), MessageCircle)
|
||||
if (isAdmin.value) {
|
||||
addToTabs('Settings', markRaw(BatchForm), Settings2)
|
||||
}
|
||||
}
|
||||
|
||||
const addToTabs = (label, component, icon) => {
|
||||
if (!tabs.value.some((tab) => tab.label === label)) {
|
||||
tabs.value.push({
|
||||
label,
|
||||
component,
|
||||
icon,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
})
|
||||
|
||||
const isStudent = computed(() => {
|
||||
return batch.data?.students?.includes(user.data?.name)
|
||||
})
|
||||
|
||||
const openAnnouncementModal = () => {
|
||||
showAnnouncementModal.value = true
|
||||
}
|
||||
|
||||
const canMakeAnnouncement = () => {
|
||||
if (readOnlyMode) return false
|
||||
if (!batch.data?.students?.length) return false
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
}
|
||||
|
||||
const batchMenu = computed(() => {
|
||||
if (!batch.data?.certification && !canMakeAnnouncement()) {
|
||||
return []
|
||||
}
|
||||
let options = [
|
||||
{
|
||||
label: __('Generate Certificates'),
|
||||
onClick() {
|
||||
openCertificateDialog.value = true
|
||||
},
|
||||
condition: () => batch.data?.certification,
|
||||
},
|
||||
{
|
||||
label: __('Make an Announcement'),
|
||||
onClick() {
|
||||
openAnnouncementModal()
|
||||
},
|
||||
condition: () => canMakeAnnouncement(),
|
||||
},
|
||||
]
|
||||
return options
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
let crumbs = [{ label: __('Batches'), route: { name: 'Batches' } }]
|
||||
crumbs.push({
|
||||
label: batch?.data?.title,
|
||||
route: { name: 'BatchDetail', params: { batchName: batch?.data?.name } },
|
||||
})
|
||||
return crumbs
|
||||
})
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: batch?.data?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.batch-description p {
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.batch-description li {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.batch-description ol {
|
||||
list-style: auto;
|
||||
margin: revert;
|
||||
padding: revert;
|
||||
}
|
||||
|
||||
.batch-description strong {
|
||||
font-weight: 600;
|
||||
color: theme('colors.gray.900') !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,576 @@
|
||||
<template>
|
||||
<div class="">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[3fr,2fr]">
|
||||
<div v-if="batchDetail.doc" class="py-5 lg:h-[88vh] lg:overflow-y-auto">
|
||||
<div class="px-5 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Details') }}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div class="space-y-5">
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="batchDetail.doc.published"
|
||||
:label="__('Published')"
|
||||
:description="__('Make the batch visible to all users.')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.title"
|
||||
:label="__('Title')"
|
||||
:required="true"
|
||||
class="w-full"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.start_date"
|
||||
:label="__('Batch Start Date')"
|
||||
type="date"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.end_date"
|
||||
:label="__('Batch End Date')"
|
||||
type="date"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.seat_count"
|
||||
:label="__('Seat Count')"
|
||||
type="number"
|
||||
class="mb-4"
|
||||
:placeholder="__('Number of seats available')"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="batchDetail.doc.allow_self_enrollment"
|
||||
:label="__('Allow Self Enrollment')"
|
||||
:description="
|
||||
__('Allow users to enroll in this batch on their own.')
|
||||
"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.start_time"
|
||||
:label="__('Session Start Time')"
|
||||
type="time"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.end_time"
|
||||
:label="__('Session End Time')"
|
||||
type="time"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.timezone"
|
||||
:label="__('Timezone')"
|
||||
type="text"
|
||||
:placeholder="__('Example: IST (+5:30)')"
|
||||
class="mb-4"
|
||||
:required="true"
|
||||
/>
|
||||
|
||||
<Link
|
||||
v-model="batchDetail.doc.category"
|
||||
doctype="LMS Category"
|
||||
:label="__('Category')"
|
||||
:inlineCreate="true"
|
||||
:onCreate="createCategory"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Certification') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 items-start">
|
||||
<div class="flex flex-col space-y-5">
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="batchDetail.doc.evaluation"
|
||||
:label="__('Evaluation')"
|
||||
:description="__('Enable evaluations for batch participants.')"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="batchDetail.doc.evaluation"
|
||||
v-model="batchDetail.doc.evaluation_end_date"
|
||||
:label="__('Evaluation End Date')"
|
||||
type="date"
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="batchDetail.doc.certification"
|
||||
:label="__('Certification')"
|
||||
:description="__('Issue certificates to batch participants.')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<MultiSelect
|
||||
v-model="instructors"
|
||||
doctype="User"
|
||||
:label="__('Instructors')"
|
||||
:required="true"
|
||||
:onCreate="() => (showMemberModal = true)"
|
||||
url="lms.lms.api.search_users_by_role"
|
||||
:searchParams="{ roles: JSON.stringify(['Batch Evaluator']) }"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.description"
|
||||
:label="__('Short Description')"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="__('Short description of the batch')"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-ink-gray-5 mb-2">
|
||||
{{ __('Batch Details') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</label>
|
||||
<TextEditor
|
||||
:content="batchDetail.doc.batch_details"
|
||||
@change="(val) => (batchDetail.doc.batch_details = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[16rem] overflow-y-scroll mb-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div class="space-y-5">
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.medium"
|
||||
type="select"
|
||||
:options="mediumOptions"
|
||||
:label="__('Medium')"
|
||||
class="mb-4"
|
||||
/>
|
||||
<Link
|
||||
ref="emailTemplateLinkRef"
|
||||
doctype="Email Template"
|
||||
:label="__('Enrollment Confirmation Email Template')"
|
||||
v-model="batchDetail.doc.confirmation_email_template"
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
if (close) close()
|
||||
showEmailTemplateModal = true
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<Uploader
|
||||
v-model="batchDetail.doc.video_link"
|
||||
:label="__('Preview Video')"
|
||||
type="video"
|
||||
:required="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Conferencing') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.conferencing_provider"
|
||||
type="select"
|
||||
:options="conferencingOptions"
|
||||
:label="__('Conferencing Provider')"
|
||||
/>
|
||||
<Link
|
||||
v-if="batchDetail.doc.conferencing_provider === 'Zoom'"
|
||||
doctype="LMS Zoom Settings"
|
||||
:label="__('Zoom Account')"
|
||||
v-model="batchDetail.doc.zoom_account"
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
openSettings('Zoom Accounts', close)
|
||||
}
|
||||
"
|
||||
/>
|
||||
<Link
|
||||
v-if="batchDetail.doc.conferencing_provider === 'Google Meet'"
|
||||
doctype="LMS Google Meet Settings"
|
||||
:label="__('Google Meet Account')"
|
||||
v-model="batchDetail.doc.google_meet_account"
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
openSettings('Google Meet Accounts', close)
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 pb-5 space-y-5 border-b mb-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Pricing') }}
|
||||
</div>
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="batchDetail.doc.paid_batch"
|
||||
:label="__('Paid Batch')"
|
||||
:description="__('Charge a fee for batch enrollment.')"
|
||||
/>
|
||||
<div
|
||||
v-if="batchDetail.doc.paid_batch"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-5"
|
||||
>
|
||||
<FormControl
|
||||
v-model="batchDetail.doc.amount"
|
||||
:label="__('Amount')"
|
||||
type="number"
|
||||
/>
|
||||
<Link
|
||||
doctype="Currency"
|
||||
v-model="batchDetail.doc.currency"
|
||||
:filters="{ enabled: 1 }"
|
||||
:label="__('Currency')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 pb-5 space-y-5">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Meta Tags') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<FormControl
|
||||
v-model="meta.description"
|
||||
:label="__('Meta Description')"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="meta.keywords"
|
||||
:label="__('Meta Keywords')"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="__('Comma separated keywords')"
|
||||
/>
|
||||
<Uploader
|
||||
v-model="batchDetail.doc.meta_image"
|
||||
:label="__('Meta Image')"
|
||||
type="image"
|
||||
:required="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-l min-w-0">
|
||||
<div class="border-b p-4">
|
||||
<BatchCourses :batch="batch" />
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<Assessments :batch="batch.data?.name" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NewMemberModal
|
||||
v-model="showMemberModal"
|
||||
:defaultRoles="['batch_evaluator']"
|
||||
@created="onInstructorCreated"
|
||||
/>
|
||||
<EmailTemplateModal
|
||||
v-model="showEmailTemplateModal"
|
||||
v-model:emailTemplates="emailTemplates"
|
||||
templateID="new"
|
||||
@created="onEmailTemplateCreated"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
computed,
|
||||
getCurrentInstance,
|
||||
inject,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
reactive,
|
||||
ref,
|
||||
toRaw,
|
||||
watch,
|
||||
nextTick,
|
||||
} from 'vue'
|
||||
import {
|
||||
FormControl,
|
||||
Switch,
|
||||
TextEditor,
|
||||
createDocumentResource,
|
||||
toast,
|
||||
call,
|
||||
createListResource,
|
||||
} from 'frappe-ui'
|
||||
import {
|
||||
createLMSCategory,
|
||||
escapeHTML,
|
||||
getMetaInfo,
|
||||
openSettings,
|
||||
sanitizeHTML,
|
||||
updateMetaInfo,
|
||||
} from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import Uploader from '@/components/Controls/Uploader.vue'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import BatchCourses from '@/pages/Batches/components/BatchCourses.vue'
|
||||
import Assessments from '@/pages/Batches/components/Assessments.vue'
|
||||
import NewMemberModal from '@/components/Modals/NewMemberModal.vue'
|
||||
import EmailTemplateModal from '@/components/Modals/EmailTemplateModal.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
const { brand } = sessionStore()
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
const instructors = ref([])
|
||||
const app = getCurrentInstance()
|
||||
const { capture } = useTelemetry()
|
||||
const { $dialog } = app.appContext.config.globalProperties
|
||||
const isDirty = ref(false)
|
||||
const originalDoc = ref(null)
|
||||
const showMemberModal = ref(false)
|
||||
const showEmailTemplateModal = ref(false)
|
||||
const emailTemplateLinkRef = ref(null)
|
||||
|
||||
const emailTemplates = createListResource({
|
||||
doctype: 'Email Template',
|
||||
fields: ['name', 'subject', 'use_html', 'response', 'response_html'],
|
||||
auto: true,
|
||||
orderBy: 'modified desc',
|
||||
cache: 'email-templates',
|
||||
})
|
||||
|
||||
const onEmailTemplateCreated = (name) => {
|
||||
batchDetail.doc.confirmation_email_template = name
|
||||
emailTemplateLinkRef.value?.reload()
|
||||
}
|
||||
|
||||
const createCategory = (name, done) => {
|
||||
createLMSCategory(name).then((categoryName) => {
|
||||
if (!categoryName) return
|
||||
batchDetail.doc.category = categoryName
|
||||
done()
|
||||
})
|
||||
}
|
||||
|
||||
const onInstructorCreated = (user) => {
|
||||
instructors.value = [...instructors.value, user.name]
|
||||
}
|
||||
|
||||
const meta = reactive({
|
||||
description: '',
|
||||
keywords: '',
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data) window.location.href = '/login'
|
||||
window.addEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const keyboardShortcut = (e) => {
|
||||
if (
|
||||
e.key === 's' &&
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
!e.target.classList.contains('ProseMirror')
|
||||
) {
|
||||
submitBatch()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', keyboardShortcut)
|
||||
})
|
||||
|
||||
const batchDetail = createDocumentResource({
|
||||
doctype: 'LMS Batch',
|
||||
name: props.batch.data?.name,
|
||||
auto: true,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => batchDetail.doc,
|
||||
() => {
|
||||
if (!batchDetail.doc) return
|
||||
|
||||
if (originalDoc.value) {
|
||||
isDirty.value =
|
||||
JSON.stringify(batchDetail.doc) !== JSON.stringify(originalDoc.value)
|
||||
}
|
||||
|
||||
updateBatchData()
|
||||
getMetaInfo('batches', batchDetail.doc?.name, meta)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const updateBatchData = () => {
|
||||
Object.keys(batchDetail.doc).forEach((key) => {
|
||||
if (key == 'instructors') {
|
||||
instructors.value = []
|
||||
batchDetail.doc.instructors.forEach((instructor) => {
|
||||
instructors.value.push(instructor.instructor)
|
||||
})
|
||||
} else if (['start_time', 'end_time'].includes(key)) {
|
||||
batchDetail.doc[key] = formatTime(batchDetail.doc[key])
|
||||
}
|
||||
})
|
||||
let checkboxes = [
|
||||
'published',
|
||||
'paid_batch',
|
||||
'allow_self_enrollment',
|
||||
'certification',
|
||||
'evaluation',
|
||||
]
|
||||
for (let idx in checkboxes) {
|
||||
let key = checkboxes[idx]
|
||||
batchDetail.doc[key] = batchDetail.doc[key] ? true : false
|
||||
}
|
||||
originalDoc.value = structuredClone(toRaw(batchDetail.doc))
|
||||
}
|
||||
|
||||
const formatTime = (timeStr) => {
|
||||
let [hours, minutes, seconds] = timeStr.split(':')
|
||||
hours = hours.length == 1 ? '0' + hours : hours
|
||||
return `${hours}:${minutes}`
|
||||
}
|
||||
|
||||
const validateFields = () => {
|
||||
batchDetail.doc.description = sanitizeHTML(batchDetail.doc.description)
|
||||
batchDetail.doc.batch_details = sanitizeHTML(batchDetail.doc.batch_details)
|
||||
|
||||
Object.keys(batchDetail.doc).forEach((key) => {
|
||||
if (
|
||||
!['description', 'batch_details'].includes(key) &&
|
||||
typeof batchDetail.doc[key] === 'string'
|
||||
) {
|
||||
batchDetail.doc[key] = escapeHTML(batchDetail.doc[key])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const submitBatch = () => {
|
||||
validateFields()
|
||||
updateBatch()
|
||||
}
|
||||
|
||||
const updateBatch = () => {
|
||||
batchDetail.setValue.submit(
|
||||
{
|
||||
...batchDetail.doc,
|
||||
instructors: instructors.value.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
updateMetaInfo('batches', data.name, meta)
|
||||
toast.success(__('Batch updated successfully'))
|
||||
nextTick(() => {
|
||||
originalDoc.value = structuredClone(data)
|
||||
isDirty.value = false
|
||||
})
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const deleteBatch = () => {
|
||||
$dialog({
|
||||
title: __('Confirm your action to delete'),
|
||||
message: __(
|
||||
'Deleting this batch will also delete all its data including enrolled students, linked courses, assessments, feedback and discussions. Are you sure you want to continue?'
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
label: __('Delete'),
|
||||
theme: 'red',
|
||||
variant: 'solid',
|
||||
onClick({ close }) {
|
||||
trashBatch(close)
|
||||
close()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const trashBatch = (close) => {
|
||||
call('lms.lms.api.delete_batch', {
|
||||
batch: props.batch.data.name,
|
||||
}).then(() => {
|
||||
toast.success(__('Batch deleted successfully'))
|
||||
close()
|
||||
router.push({
|
||||
name: 'Batches',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const conferencingOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: '',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
label: __('Zoom'),
|
||||
value: 'Zoom',
|
||||
},
|
||||
{
|
||||
label: __('Google Meet'),
|
||||
value: 'Google Meet',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const mediumOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Online'),
|
||||
value: 'Online',
|
||||
},
|
||||
{
|
||||
label: __('Offline'),
|
||||
value: 'Offline',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
submitBatch,
|
||||
deleteBatch,
|
||||
isDirty,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="m-5 pb-10">
|
||||
<div class="flex justify-between w-full">
|
||||
<div class="md:w-2/3">
|
||||
<div class="text-3xl font-semibold text-ink-gray-9">
|
||||
{{ batch.data.title }}
|
||||
</div>
|
||||
<div class="my-3 leading-6 text-ink-gray-7">
|
||||
{{ batch.data.description }}
|
||||
</div>
|
||||
<div class="flex avatar-group overlap">
|
||||
<div
|
||||
class="h-6 mr-1"
|
||||
:class="{
|
||||
'avatar-group overlap': batch.data.instructors.length > 1,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in batch.data.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</div>
|
||||
<CourseInstructors :instructors="batch.data.instructors" />
|
||||
</div>
|
||||
<BatchOverlay :batch="batch" class="md:hidden mt-5" />
|
||||
<div
|
||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-10"
|
||||
v-html="batch.data.batch_details"
|
||||
></div>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<BatchOverlay :batch="batch" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="courses.data?.length">
|
||||
<div class="flex items-center mt-10">
|
||||
<div class="text-2xl font-semibold text-ink-gray-9">
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mt-5">
|
||||
<div
|
||||
v-if="courses.data?.length"
|
||||
v-for="course in courses.data"
|
||||
:key="course.course"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'CourseDetail',
|
||||
params: {
|
||||
courseName: course.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<CourseCard :course="course" :key="course.name" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="batch.data.batch_details_raw">
|
||||
<div
|
||||
v-html="batch.data.batch_details_raw"
|
||||
class="batch-description"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { createResource } from 'frappe-ui'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import BatchOverlay from '@/pages/Batches/components/BatchOverlay.vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const courses = createResource({
|
||||
url: 'lms.lms.utils.get_batch_courses',
|
||||
params: {
|
||||
batch: props.batch?.data?.name,
|
||||
},
|
||||
cache: ['batchCourses', props.batch?.data?.name],
|
||||
auto: true,
|
||||
})
|
||||
</script>
|
||||
@@ -10,10 +10,7 @@
|
||||
label: __('New Batch'),
|
||||
icon: 'users',
|
||||
onClick() {
|
||||
router.push({
|
||||
name: 'BatchForm',
|
||||
params: { batchName: 'new' },
|
||||
})
|
||||
showBatchModal = true
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -45,20 +42,6 @@
|
||||
</Button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<!-- <router-link
|
||||
v-if="canCreateBatch()"
|
||||
:to="{
|
||||
name: 'BatchForm',
|
||||
params: { batchName: 'new' },
|
||||
}"
|
||||
>
|
||||
<Button variant="solid">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Create') }}
|
||||
</Button>
|
||||
</router-link> -->
|
||||
</header>
|
||||
<div class="p-5 pb-10">
|
||||
<div
|
||||
@@ -95,10 +78,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="certification"
|
||||
:label="__('Certification')"
|
||||
type="checkbox"
|
||||
:description="__('Only show batches that offer a certificate.')"
|
||||
@change="updateBatches()"
|
||||
/>
|
||||
</div>
|
||||
@@ -125,6 +109,11 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<NewBatchModal
|
||||
v-if="showBatchModal"
|
||||
v-model="showBatchModal"
|
||||
:batches="batches"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
@@ -134,6 +123,7 @@ import {
|
||||
Dropdown,
|
||||
FormControl,
|
||||
Select,
|
||||
Switch,
|
||||
TabButtons,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
@@ -141,8 +131,9 @@ import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ChevronDown, Plus } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import BatchCard from '@/components/BatchCard.vue'
|
||||
import BatchCard from '@/pages/Batches/components/BatchCard.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import NewBatchModal from '@/pages/Batches/components/NewBatchModal.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
@@ -155,10 +146,11 @@ const title = ref('')
|
||||
const certification = ref(false)
|
||||
const filters = ref({})
|
||||
const is_student = computed(() => user.data?.is_student)
|
||||
const currentTab = ref(is_student.value ? 'All' : 'Upcoming')
|
||||
const currentTab = ref(is_student.value ? 'all' : 'upcoming')
|
||||
const orderBy = ref('start_date')
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const router = useRouter()
|
||||
const showBatchModal = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
setFiltersFromQuery()
|
||||
@@ -245,7 +237,7 @@ const updateTabFilter = () => {
|
||||
if (!user.data) {
|
||||
return
|
||||
}
|
||||
if (currentTab.value == 'Enrolled' && is_student.value) {
|
||||
if (currentTab.value == 'enrolled' && is_student.value) {
|
||||
filters.value['enrolled'] = 1
|
||||
delete filters.value['start_date']
|
||||
delete filters.value['published']
|
||||
@@ -256,20 +248,20 @@ const updateTabFilter = () => {
|
||||
delete filters.value['start_date']
|
||||
delete filters.value['published']
|
||||
orderBy.value = 'start_date desc'
|
||||
if (currentTab.value == 'Upcoming') {
|
||||
if (currentTab.value == 'upcoming') {
|
||||
filters.value['start_date'] = ['>=', dayjs().format('YYYY-MM-DD')]
|
||||
filters.value['published'] = 1
|
||||
orderBy.value = 'start_date'
|
||||
} else if (currentTab.value == 'Archived') {
|
||||
} else if (currentTab.value == 'archived') {
|
||||
filters.value['start_date'] = ['<=', dayjs().format('YYYY-MM-DD')]
|
||||
} else if (currentTab.value == 'Unpublished') {
|
||||
} else if (currentTab.value == 'unpublished') {
|
||||
filters.value['published'] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateStudentFilter = () => {
|
||||
if (!user.data || (is_student.value && currentTab.value != 'Enrolled')) {
|
||||
if (!user.data || (is_student.value && currentTab.value != 'enrolled')) {
|
||||
filters.value['start_date'] = ['>=', dayjs().format('YYYY-MM-DD')]
|
||||
filters.value['published'] = 1
|
||||
}
|
||||
@@ -319,6 +311,7 @@ const batchTabs = computed(() => {
|
||||
let tabs = [
|
||||
{
|
||||
label: __('All'),
|
||||
value: 'all',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -327,11 +320,11 @@ const batchTabs = computed(() => {
|
||||
user.data?.is_instructor ||
|
||||
user.data?.is_evaluator
|
||||
) {
|
||||
tabs.push({ label: __('Upcoming') })
|
||||
tabs.push({ label: __('Archived') })
|
||||
tabs.push({ label: __('Unpublished') })
|
||||
tabs.push({ label: __('Upcoming'), value: 'upcoming' })
|
||||
tabs.push({ label: __('Archived'), value: 'archived' })
|
||||
tabs.push({ label: __('Unpublished'), value: 'unpublished' })
|
||||
} else if (user.data) {
|
||||
tabs.push({ label: __('Enrolled') })
|
||||
tabs.push({ label: __('Enrolled'), value: 'enrolled' })
|
||||
}
|
||||
return tabs
|
||||
})
|
||||
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div v-if="batch?.data" class="p-5">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-5 mb-8">
|
||||
<NumberChartGraph
|
||||
:title="__('Enrolled')"
|
||||
:value="formatAmount(batch.data?.students?.length) || 0"
|
||||
/>
|
||||
|
||||
<NumberChartGraph
|
||||
:title="__('Certified')"
|
||||
:value="certificationCount.data || 0"
|
||||
/>
|
||||
|
||||
<NumberChartGraph
|
||||
class="border rounded-md"
|
||||
:title="__('Courses')"
|
||||
:value="batch?.data?.courses?.length || 0"
|
||||
/>
|
||||
|
||||
<NumberChartGraph
|
||||
class="border rounded-md"
|
||||
:title="__('Assessments')"
|
||||
:value="batch?.data?.assessments?.length || 0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-5 items-start">
|
||||
<div class="border rounded-lg py-3 px-4 order-2 lg:order-1">
|
||||
<div class="flex items-center justify-between space-x-2 mb-3">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Students') }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<FormControl
|
||||
v-model="searchFilter"
|
||||
:placeholder="__('Search by name')"
|
||||
type="text"
|
||||
/>
|
||||
<Button @click="showEnrollmentModal = true">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Enroll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="students.loading || students.data?.length"
|
||||
class="max-h-[63vh] overflow-y-auto"
|
||||
>
|
||||
<ListView
|
||||
:columns="studentColumns"
|
||||
:rows="students.data"
|
||||
rowKey="name"
|
||||
:options="{
|
||||
selectable: false,
|
||||
showTooltip: false,
|
||||
onRowClick: (row: any) => {
|
||||
currentStudent = row.member
|
||||
showProgressModal = true
|
||||
},
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-white border-b rounded-none p-2"
|
||||
>
|
||||
<ListHeaderItem
|
||||
:item="item"
|
||||
v-for="item in studentColumns"
|
||||
:key="item.key"
|
||||
>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows v-for="row in students.data" class="max-h-[500px]">
|
||||
<ListRow :row="row">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem
|
||||
:item="row[column.key]"
|
||||
:align="column.align"
|
||||
class="w-full"
|
||||
>
|
||||
<template #prefix>
|
||||
<div v-if="column.key == 'member_name'">
|
||||
<Avatar
|
||||
class="flex items-center"
|
||||
:image="row['member_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<!-- <ProgressBar
|
||||
v-else-if="column.key == 'progress'"
|
||||
:progress="Math.ceil(row[column.key])"
|
||||
class="!mx-0 !mr-4"
|
||||
/> -->
|
||||
</template>
|
||||
<div v-if="column.key == 'creation'">
|
||||
{{ dayjs(row[column.key]).format('DD MMM YYYY') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="column.key == 'progress'"
|
||||
class="text-xs !mx-0 w-5"
|
||||
>
|
||||
{{ Math.ceil(row[column.key]) }}%
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key].toString() }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
</ListView>
|
||||
<div
|
||||
v-if="students.data && students.hasNextPage"
|
||||
class="flex justify-center my-3"
|
||||
>
|
||||
<Button @click="students.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="order-1 lg:order-2">
|
||||
<AxisChart
|
||||
v-if="showProgressChart"
|
||||
class="border rounded-lg p-3 min-h-[300px]"
|
||||
:config="{
|
||||
data: filteredChartData,
|
||||
title: __('Batch Summary'),
|
||||
subtitle: __('Progress of students in courses and assessments'),
|
||||
xAxis: {
|
||||
key: 'task',
|
||||
title: 'Tasks',
|
||||
type: 'category',
|
||||
},
|
||||
yAxis: {
|
||||
title: __('Number of Students'),
|
||||
echartOptions: {
|
||||
minInterval: 1,
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'value',
|
||||
type: 'bar',
|
||||
},
|
||||
],
|
||||
}"
|
||||
/>
|
||||
|
||||
<div class="p-4 border rounded-lg mt-5">
|
||||
<BatchFeedback v-if="batch.data" :batch="batch.data.name" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StudentModal
|
||||
v-if="showEnrollmentModal"
|
||||
v-model="showEnrollmentModal"
|
||||
:batch="batch"
|
||||
:students="students"
|
||||
/>
|
||||
<BatchStudentProgress
|
||||
v-if="showProgressModal"
|
||||
v-model="showProgressModal"
|
||||
:student="currentStudent"
|
||||
:batch="batch?.data?.name"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AxisChart,
|
||||
createResource,
|
||||
createListResource,
|
||||
dayjs,
|
||||
FormControl,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
Avatar,
|
||||
Button,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { formatAmount } from '@/utils'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import BatchFeedback from '@/pages/Batches/components/BatchFeedback.vue'
|
||||
import BatchStudentProgress from '@/pages/Batches/components/BatchStudentProgress.vue'
|
||||
import NumberChartGraph from '@/components/NumberChartGraph.vue'
|
||||
import StudentModal from '@/components/Modals/StudentModal.vue'
|
||||
|
||||
const searchFilter = ref<string | null>(null)
|
||||
const showEnrollmentModal = ref<boolean>(false)
|
||||
const showProgressModal = ref<boolean>(false)
|
||||
const currentStudent = ref<any>(null)
|
||||
|
||||
const props = defineProps<{
|
||||
batch: { [key: string]: any } | null
|
||||
}>()
|
||||
|
||||
const chartData = createResource({
|
||||
url: 'lms.lms.utils.get_batch_chart_data',
|
||||
cache: ['batch_chart_data', props.batch?.data?.name],
|
||||
params: { batch: props.batch?.data?.name },
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const certificationCount = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
cache: ['batch_certificate_count', props.batch?.data?.name],
|
||||
params: {
|
||||
doctype: 'LMS Certificate',
|
||||
filters: { batch_name: props.batch?.data?.name },
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const students = createListResource({
|
||||
doctype: 'LMS Batch Enrollment',
|
||||
filters: {
|
||||
batch: props.batch?.data?.name,
|
||||
},
|
||||
fields: [
|
||||
'name',
|
||||
'member',
|
||||
'member_name',
|
||||
'member_username',
|
||||
'member_image',
|
||||
'creation',
|
||||
],
|
||||
orderBy: 'creation desc',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const filteredChartData = computed(() =>
|
||||
(chartData.data || []).filter((item: { value: number }) => item.value > 0)
|
||||
)
|
||||
|
||||
watch(searchFilter, () => {
|
||||
let filters: Record<string, any> = {
|
||||
batch: props.batch?.data?.name,
|
||||
}
|
||||
|
||||
if (searchFilter.value) {
|
||||
filters.member_name = ['like', `%${searchFilter.value}%`]
|
||||
}
|
||||
|
||||
students.update({ filters })
|
||||
students.reload()
|
||||
})
|
||||
|
||||
const studentColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Name'),
|
||||
key: 'member_name',
|
||||
width: '40%',
|
||||
},
|
||||
{
|
||||
label: __('Enrolled On'),
|
||||
key: 'creation',
|
||||
align: 'right',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const showProgressChart = computed(
|
||||
() =>
|
||||
students.data?.length &&
|
||||
(props.batch?.data?.courses?.length ||
|
||||
props.batch?.data?.assessments?.length)
|
||||
)
|
||||
</script>
|
||||
+19
-15
@@ -15,20 +15,18 @@
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Subject') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<Input type="text" v-model="announcement.subject" />
|
||||
</div>
|
||||
<div class="">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Reply To') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<Input type="text" v-model="announcement.replyTo" />
|
||||
</div>
|
||||
<FormControl
|
||||
:label="__('Subject')"
|
||||
type="text"
|
||||
v-model="announcement.subject"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Reply To')"
|
||||
type="text"
|
||||
v-model="announcement.replyTo"
|
||||
:required="true"
|
||||
/>
|
||||
<div class="mb-4">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Announcement') }}
|
||||
@@ -45,7 +43,13 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, Input, TextEditor, createResource, toast } from 'frappe-ui'
|
||||
import {
|
||||
Dialog,
|
||||
FormControl,
|
||||
TextEditor,
|
||||
createResource,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const show = defineModel()
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="w-[90%] lg:w-[75%] mx-auto mt-5">
|
||||
<div class="text-ink-gray-9 font-semibold text-lg mb-5">
|
||||
{{ __('Announcements') }}
|
||||
</div>
|
||||
<div v-if="communications.data?.length">
|
||||
<div v-for="comm in communications.data">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center">
|
||||
<Avatar :label="comm.sender_full_name" size="lg" />
|
||||
<div class="ml-2 text-ink-gray-7">
|
||||
{{ comm.sender_full_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
{{ timeAgo(comm.communication_date) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="prose prose-sm bg-surface-menu-bar !min-w-full px-4 py-2 rounded-md"
|
||||
v-html="comm.content"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-ink-gray-7 leading-5">
|
||||
{{ __('No announcements have been made yet for this batch') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, Avatar } from 'frappe-ui'
|
||||
import { timeAgo } from '@/utils'
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const communications = createResource({
|
||||
url: 'lms.lms.api.get_announcements',
|
||||
makeParams(value) {
|
||||
return {
|
||||
batch: props.batch.data?.name,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
cache: ['announcement', props.batch],
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.prose-sm p {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
</style>
|
||||
+15
-18
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
<div class="text-ink-gray-9 font-semibold">
|
||||
{{ __('Assessments') }}
|
||||
</div>
|
||||
<Button v-if="canAddAssessments()" @click="showModal = true">
|
||||
@@ -16,6 +16,7 @@
|
||||
:columns="getAssessmentColumns()"
|
||||
:rows="assessments.data"
|
||||
row-key="name"
|
||||
class="border rounded-lg"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
getRowRoute: (row) => getRowRoute(row),
|
||||
@@ -23,20 +24,17 @@
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in getAssessmentColumns()">
|
||||
<template #prefix="{ item }">
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="h-4 w-4 stroke-1.5 ml-4"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in assessments.data">
|
||||
<ListRow
|
||||
:row="row"
|
||||
v-for="row in assessments.data"
|
||||
class="!rounded-none"
|
||||
>
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<div v-if="column.key == 'assessment_type'">
|
||||
@@ -57,7 +55,7 @@
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<ListSelectBanner class="!min-w-0">
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
@@ -71,8 +69,8 @@
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-ink-gray-5">
|
||||
{{ __('No Assessments') }}
|
||||
<div v-else class="text-ink-gray-7">
|
||||
{{ __('No assessments added to this batch') }}
|
||||
</div>
|
||||
</div>
|
||||
<AssessmentModal
|
||||
@@ -208,20 +206,19 @@ const canAddAssessments = () => {
|
||||
const getAssessmentColumns = () => {
|
||||
let columns = [
|
||||
{
|
||||
label: 'Assessment',
|
||||
label: __('Assessment'),
|
||||
key: 'title',
|
||||
width: '25rem',
|
||||
},
|
||||
{
|
||||
label: 'Type',
|
||||
label: __('Type'),
|
||||
key: 'assessment_type',
|
||||
width: '15rem',
|
||||
width: '10rem',
|
||||
},
|
||||
]
|
||||
|
||||
if (!user.data?.is_moderator) {
|
||||
columns.push({
|
||||
label: 'Status/Percentage',
|
||||
label: __('Status/Percentage'),
|
||||
key: 'status',
|
||||
align: 'left',
|
||||
width: '10rem',
|
||||
+19
-16
@@ -6,24 +6,26 @@
|
||||
<div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9">
|
||||
{{ batch.title }}
|
||||
</div>
|
||||
<div
|
||||
<Badge
|
||||
v-if="batch.seat_count && batch.seats_left > 0"
|
||||
class="text-xs bg-green-100 text-green-700 self-start px-2 py-0.5 rounded-md"
|
||||
>
|
||||
{{ batch.seats_left }}
|
||||
<span v-if="batch.seats_left > 1">
|
||||
{{ __('Seats Left') }}
|
||||
</span>
|
||||
<span v-else-if="batch.seats_left == 1">
|
||||
{{ __('Seat Left') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
variant="subtle"
|
||||
theme="green"
|
||||
size="md"
|
||||
class="self-start"
|
||||
:label="
|
||||
batch.seats_left +
|
||||
' ' +
|
||||
(batch.seats_left > 1 ? __('Seats Left') : __('Seat Left'))
|
||||
"
|
||||
/>
|
||||
<Badge
|
||||
v-else-if="batch.seat_count && batch.seats_left <= 0"
|
||||
class="text-xs bg-red-100 text-red-700 self-start px-2 py-0.5 rounded-md"
|
||||
>
|
||||
{{ __('Sold Out') }}
|
||||
</div>
|
||||
variant="subtle"
|
||||
theme="red"
|
||||
size="md"
|
||||
class="self-start"
|
||||
:label="__('Sold Out')"
|
||||
/>
|
||||
<div class="short-introduction text-sm text-ink-gray-7">
|
||||
{{ batch.description }}
|
||||
</div>
|
||||
@@ -70,6 +72,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Badge } from 'frappe-ui'
|
||||
import { formatTime } from '@/utils'
|
||||
import { Clock, Globe } from 'lucide-vue-next'
|
||||
import DateRange from '@/components/Common/DateRange.vue'
|
||||
+32
-56
@@ -1,21 +1,22 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="font-medium text-ink-gray-9">
|
||||
<div class="text-ink-gray-9 font-semibold">
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
<Button v-if="canSeeAddButton()" @click="openCourseModal()">
|
||||
<Button v-if="isAdmin()" @click="openCourseModal()">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="courses.data?.length">
|
||||
<div v-if="courses.data?.length" class="text-sm">
|
||||
<ListView
|
||||
:columns="getCoursesColumns()"
|
||||
:rows="courses.data"
|
||||
row-key="batch_course"
|
||||
row-key="name"
|
||||
class="border rounded-lg"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
selectable: user.data?.is_student ? false : true,
|
||||
@@ -26,20 +27,13 @@
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in getCoursesColumns()">
|
||||
<template #prefix="{ item }">
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="h-4 w-4 stroke-1.5 ml-4"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in courses.data">
|
||||
<ListRow :row="row" v-for="row in courses.data" class="!rounded-none">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<div>
|
||||
@@ -49,7 +43,7 @@
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<ListSelectBanner class="!min-w-0">
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
@@ -63,21 +57,21 @@
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-ink-gray-5">
|
||||
{{ __('No courses added') }}
|
||||
<div v-else class="text-ink-gray-7">
|
||||
{{ __('No courses added to this batch') }}
|
||||
</div>
|
||||
<BatchCourseModal
|
||||
v-model="showCourseModal"
|
||||
:batch="batch"
|
||||
:batch="batch.data?.name"
|
||||
v-model:courses="courses"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, inject } from 'vue'
|
||||
import { ref, inject, nextTick } from 'vue'
|
||||
import BatchCourseModal from '@/components/Modals/BatchCourseModal.vue'
|
||||
import {
|
||||
createResource,
|
||||
createListResource,
|
||||
Button,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
@@ -96,16 +90,20 @@ const user = inject('$user')
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const courses = createResource({
|
||||
url: 'lms.lms.utils.get_batch_courses',
|
||||
params: {
|
||||
batch: props.batch,
|
||||
const courses = createListResource({
|
||||
doctype: 'Batch Course',
|
||||
filters: {
|
||||
parent: props.batch.data?.name,
|
||||
parenttype: 'LMS Batch',
|
||||
},
|
||||
fields: ['name', 'course', 'title', 'evaluator'],
|
||||
parent: 'LMS Batch',
|
||||
orderBy: 'idx',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
@@ -118,47 +116,25 @@ const getCoursesColumns = () => {
|
||||
{
|
||||
label: 'Title',
|
||||
key: 'title',
|
||||
width: 2,
|
||||
},
|
||||
{
|
||||
label: 'Lessons',
|
||||
key: 'lessons',
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
label: 'Enrollments',
|
||||
align: 'right',
|
||||
key: 'enrollments',
|
||||
label: 'Evaluator',
|
||||
key: 'evaluator',
|
||||
width: '10rem',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const deleteCourses = createResource({
|
||||
url: 'lms.lms.api.delete_documents',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'Batch Course',
|
||||
documents: values.courses,
|
||||
}
|
||||
},
|
||||
})
|
||||
const removeCourses = async (selections, unselectAll) => {
|
||||
for (const course of selections) {
|
||||
await courses.delete.submit(course)
|
||||
}
|
||||
|
||||
const removeCourses = (selections, unselectAll) => {
|
||||
deleteCourses.submit(
|
||||
{
|
||||
courses: Array.from(selections),
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
courses.reload()
|
||||
toast.success(__('Courses deleted successfully'))
|
||||
unselectAll()
|
||||
},
|
||||
}
|
||||
)
|
||||
unselectAll()
|
||||
toast.success(__('Courses deleted successfully'))
|
||||
}
|
||||
|
||||
const canSeeAddButton = () => {
|
||||
const isAdmin = () => {
|
||||
if (readOnlyMode) {
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div class="h-[88vh]">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[2fr,1fr] gap-5">
|
||||
<div class="p-5">
|
||||
<div class="mb-8 space-y-2">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Curriculum') }}
|
||||
</div>
|
||||
<div class="text-ink-gray-7">
|
||||
{{
|
||||
__(
|
||||
"As a part of this batch's curriculum you will have to complete the following courses and assessments."
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-10">
|
||||
<div>
|
||||
<div class="text-ink-gray-9 font-semibold mb-4">
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
<ListView
|
||||
v-if="batch.data?.courses?.length"
|
||||
:columns="courseColumns"
|
||||
:rows="batch.data?.courses"
|
||||
row-key="name"
|
||||
class="border rounded-lg"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
selectable: user.data?.is_student ? false : true,
|
||||
getRowRoute: (row) => ({
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: row.course },
|
||||
}),
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
|
||||
>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow
|
||||
:row="row"
|
||||
v-for="row in batch.data?.courses"
|
||||
class="!rounded-none text-sm"
|
||||
>
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<div v-if="column.key === 'progress'">
|
||||
{{ getProgress(row.course) }}%
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
</ListView>
|
||||
<div v-else class="text-ink-gray-7">
|
||||
{{ __('No courses added to this batch') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- <BatchCourses :batch="batch" /> -->
|
||||
<Assessments :batch="batch.data.name" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-l h-[88vh] divide-y">
|
||||
<div v-if="batch.data?.evaluation" class="p-4 mb-5">
|
||||
<UpcomingEvaluations
|
||||
:batch="batch.data.name"
|
||||
:endDate="batch.data.evaluation_end_date"
|
||||
:courses="batch.data.courses"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<BatchFeedback :batch="batch.data?.name" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
import {
|
||||
createListResource,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
} from 'frappe-ui'
|
||||
import Assessments from '@/pages/Batches/components/Assessments.vue'
|
||||
import BatchCourses from '@/pages/Batches/components/BatchCourses.vue'
|
||||
import BatchFeedback from '@/pages/Batches/components/BatchFeedback.vue'
|
||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
isStudent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const progressList = createListResource({
|
||||
doctype: 'LMS Enrollment',
|
||||
filters: {
|
||||
member: user.data?.name,
|
||||
course: ['in', props.batch.data?.courses?.map((c) => c.course)],
|
||||
},
|
||||
fields: ['course', 'progress', 'name'],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const getProgress = (course) => {
|
||||
const progress = progressList.data?.find((p) => p.course === course)
|
||||
return progress ? Math.round(progress.progress) : 0
|
||||
}
|
||||
|
||||
const courseColumns = [
|
||||
{
|
||||
key: 'title',
|
||||
label: __('Course'),
|
||||
},
|
||||
{
|
||||
key: 'progress',
|
||||
label: __('Progress'),
|
||||
align: 'right',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
+68
-49
@@ -1,63 +1,77 @@
|
||||
<template>
|
||||
<div v-if="user.data?.is_student">
|
||||
<div>
|
||||
<div class="leading-5 mb-4 text-ink-gray-7">
|
||||
<div v-if="readOnly">
|
||||
{{ __('Thank you for providing your feedback.') }}
|
||||
<span
|
||||
@click="showFeedbackForm = !showFeedbackForm"
|
||||
class="underline cursor-pointer"
|
||||
>{{ __('Click here') }}</span
|
||||
>
|
||||
{{ __('to view your feedback.') }}
|
||||
<div>
|
||||
<div class="flex justify-between mb-5">
|
||||
<div class="space-y-1">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Feedback') }}
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ __('Help us improve by providing your feedback.') }}
|
||||
<div
|
||||
v-if="feedbackList.data?.length && isAdmin"
|
||||
class="leading-5 text-ink-gray-7 text-sm mb-2 mt-5"
|
||||
>
|
||||
{{ __('Average Feedback Received') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4" :class="showFeedbackForm ? 'block' : 'hidden'">
|
||||
<div class="space-y-4">
|
||||
<Rating
|
||||
v-for="key in ratingKeys"
|
||||
v-model="feedback[key]"
|
||||
:label="__(convertToTitleCase(key))"
|
||||
<Button
|
||||
v-if="feedbackList.data?.length && isAdmin"
|
||||
variant="outline"
|
||||
@click="showAllFeedback = true"
|
||||
>
|
||||
{{ __('View all feedback') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="user.data?.is_student">
|
||||
<div>
|
||||
<div class="leading-5 mb-4 text-ink-gray-7">
|
||||
<div v-if="readOnly">
|
||||
{{ __('Thank you for providing your feedback.') }}
|
||||
<span
|
||||
@click="showFeedbackForm = !showFeedbackForm"
|
||||
class="underline cursor-pointer"
|
||||
>{{ __('Click here') }}</span
|
||||
>
|
||||
{{ __('to view your feedback.') }}
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ __('Help us improve by providing your feedback.') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4" :class="showFeedbackForm ? 'block' : 'hidden'">
|
||||
<div class="space-y-4">
|
||||
<Rating
|
||||
v-for="key in ratingKeys"
|
||||
v-model="feedback[key]"
|
||||
:label="__(convertToTitleCase(key))"
|
||||
:readonly="readOnly"
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="feedback.feedback"
|
||||
type="textarea"
|
||||
:label="__('Feedback')"
|
||||
:rows="9"
|
||||
:readonly="readOnly"
|
||||
/>
|
||||
<Button v-if="!readOnly" @click="submitFeedback">
|
||||
{{ __('Submit Feedback') }}
|
||||
</Button>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="feedback.feedback"
|
||||
type="textarea"
|
||||
:label="__('Feedback')"
|
||||
:rows="9"
|
||||
:readonly="readOnly"
|
||||
/>
|
||||
<Button v-if="!readOnly" @click="submitFeedback">
|
||||
{{ __('Submit Feedback') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="feedbackList.data?.length">
|
||||
<div class="leading-5 text-sm mb-2 mt-5">
|
||||
{{ __('Average Feedback Received') }}
|
||||
<div v-else-if="feedbackList.data?.length">
|
||||
<div class="space-y-4">
|
||||
<Rating
|
||||
v-for="key in ratingKeys"
|
||||
v-model="average[key]"
|
||||
:label="__(convertToTitleCase(key))"
|
||||
:readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<Rating
|
||||
v-for="key in ratingKeys"
|
||||
v-model="average[key]"
|
||||
:label="__(convertToTitleCase(key))"
|
||||
:readonly="true"
|
||||
/>
|
||||
<div v-else class="text-ink-gray-7 leading-5">
|
||||
{{ __('No feedback received yet.') }}
|
||||
</div>
|
||||
|
||||
<Button variant="outline" class="mt-5" @click="showAllFeedback = true">
|
||||
{{ __('View all feedback') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="text-ink-gray-7 mt-5 leading-5">
|
||||
{{ __('No feedback received yet.') }}
|
||||
</div>
|
||||
<FeedbackModal
|
||||
v-if="feedbackList.data?.length"
|
||||
@@ -66,7 +80,7 @@
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { inject, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, inject, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { convertToTitleCase } from '@/utils'
|
||||
import { Button, createListResource, FormControl, Rating } from 'frappe-ui'
|
||||
import FeedbackModal from '@/components/Modals/FeedbackModal.vue'
|
||||
@@ -159,10 +173,15 @@ const submitFeedback = () => {
|
||||
onSuccess: () => {
|
||||
feedbackList.reload()
|
||||
showFeedbackForm.value = false
|
||||
readOnly.value = true
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.feedback-list > button > div {
|
||||
+29
-69
@@ -1,31 +1,32 @@
|
||||
<template>
|
||||
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72">
|
||||
<div
|
||||
<Badge
|
||||
v-if="batch.data.seat_count && batch.data.seats_left > 0"
|
||||
class="text-sm bg-green-100 text-green-700 px-2 py-1 rounded-md"
|
||||
variant="subtle"
|
||||
theme="green"
|
||||
size="md"
|
||||
:class="
|
||||
batch.data.amount || batch.data.courses.length
|
||||
? 'float-right'
|
||||
: 'w-fit mb-4'
|
||||
"
|
||||
>
|
||||
{{ batch.data.seats_left }}
|
||||
<span v-if="batch.data.seats_left > 1">
|
||||
{{ __('Seats Left') }}
|
||||
</span>
|
||||
<span v-else-if="batch.data.seats_left == 1">
|
||||
{{ __('Seat Left') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
:label="
|
||||
batch.data.seats_left +
|
||||
' ' +
|
||||
(batch.data.seats_left > 1 ? __('Seats Left') : __('Seat Left'))
|
||||
"
|
||||
/>
|
||||
<Badge
|
||||
v-else-if="batch.data.seat_count && batch.data.seats_left <= 0"
|
||||
class="text-xs bg-red-100 text-red-700 float-right px-2 py-0.5 rounded-md"
|
||||
>
|
||||
{{ __('Sold Out') }}
|
||||
</div>
|
||||
variant="subtle"
|
||||
theme="red"
|
||||
size="md"
|
||||
class="float-right"
|
||||
:label="__('Sold Out')"
|
||||
/>
|
||||
<div
|
||||
v-if="batch.data.amount"
|
||||
class="text-lg font-semibold mb-3 text-ink-gray-9"
|
||||
class="text-lg font-semibold mb-5 text-ink-gray-9"
|
||||
>
|
||||
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
|
||||
</div>
|
||||
@@ -55,26 +56,7 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!readOnlyMode">
|
||||
<router-link
|
||||
v-if="canAccessBatch"
|
||||
:to="{
|
||||
name: 'Batch',
|
||||
params: {
|
||||
batchName: batch.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" class="w-full mt-4">
|
||||
<template #prefix>
|
||||
<LogIn v-if="isStudent" class="size-4 stroke-1.5" />
|
||||
<Settings v-else class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ isStudent ? __('Visit Batch') : __('Manage Batch') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<div v-if="!readOnlyMode && !canAccessBatch">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Billing',
|
||||
@@ -83,13 +65,13 @@
|
||||
name: batch.data.name,
|
||||
},
|
||||
}"
|
||||
v-else-if="
|
||||
v-if="
|
||||
batch.data.paid_batch &&
|
||||
batch.data.seats_left > 0 &&
|
||||
batch.data.accept_enrollments
|
||||
"
|
||||
>
|
||||
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
|
||||
<Button class="w-full mt-4" variant="solid">
|
||||
<template #prefix>
|
||||
<CreditCard class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
@@ -113,30 +95,12 @@
|
||||
</template>
|
||||
{{ __('Enroll Now') }}
|
||||
</Button>
|
||||
<router-link
|
||||
v-if="canEditBatch"
|
||||
:to="{
|
||||
name: 'BatchForm',
|
||||
params: {
|
||||
batchName: batch.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button class="w-full mt-2">
|
||||
<template #prefix>
|
||||
<Pencil class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Edit') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { inject, computed } from 'vue'
|
||||
import { Button, createResource, toast } from 'frappe-ui'
|
||||
import { Badge, Button, createResource, toast } from 'frappe-ui'
|
||||
import {
|
||||
BookOpen,
|
||||
Clock,
|
||||
@@ -173,7 +137,7 @@ const enroll = createResource({
|
||||
|
||||
const enrollInBatch = () => {
|
||||
if (!user.data) {
|
||||
window.location.href = `/login?redirect-to=/batches/details/${props.batch.data.name}`
|
||||
window.location.href = `/login?redirect-to=/batches/${props.batch.data.name}`
|
||||
}
|
||||
enroll.submit(
|
||||
{},
|
||||
@@ -187,6 +151,10 @@ const enrollInBatch = () => {
|
||||
},
|
||||
})
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(__(err.messages?.[0] || err))
|
||||
console.error(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -205,14 +173,6 @@ const isEvaluator = computed(() => {
|
||||
return user.data?.is_evaluator
|
||||
})
|
||||
|
||||
const isInstructor = computed(() => {
|
||||
return (
|
||||
props.batch.data?.instructors?.filter(
|
||||
(instructor) => instructor.name === user.data?.name
|
||||
).length > 0
|
||||
)
|
||||
})
|
||||
|
||||
const canAccessBatch = computed(() => {
|
||||
if (!user.data) {
|
||||
return false
|
||||
@@ -220,7 +180,7 @@ const canAccessBatch = computed(() => {
|
||||
return isModerator.value || isStudent.value || isEvaluator.value
|
||||
})
|
||||
|
||||
const canEditBatch = computed(() => {
|
||||
return isModerator.value || isInstructor.value
|
||||
const isAdmin = computed(() => {
|
||||
return isModerator.value || isEvaluator.value
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: 'xl',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div v-if="studentDetails.data" class="p-5 space-y-10 text-sm">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Avatar :image="studentDetails.data.user_image" size="3xl" />
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ studentDetails.data.full_name }}
|
||||
</div>
|
||||
<Badge
|
||||
v-if="
|
||||
Object.keys(studentDetails.data.assessments).length ||
|
||||
Object.keys(studentDetails.data.courses).length
|
||||
"
|
||||
:theme="studentDetails.data.progress === 100 ? 'green' : 'red'"
|
||||
>
|
||||
{{ studentDetails.data.progress }}% {{ __('Complete') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-7">
|
||||
{{ studentDetails.data.email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Assessments -->
|
||||
<ListView
|
||||
:columns="assessmentColumns"
|
||||
:rows="studentDetails.data.assessments"
|
||||
row-key="title"
|
||||
class="border border-outline-gray-modals rounded-lg"
|
||||
:options="{
|
||||
selectable: false,
|
||||
showTooltip: false,
|
||||
onRowClick: (row: any) => {
|
||||
redirectToAssessment(row)
|
||||
}
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
|
||||
>
|
||||
</ListHeader>
|
||||
<ListRows v-for="row in studentDetails.data.assessments">
|
||||
<ListRow :row="row" class="!rounded-none">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem
|
||||
:item="row[column.key]"
|
||||
:align="column.align"
|
||||
class="w-full"
|
||||
>
|
||||
<div
|
||||
v-if="column.key == 'status' && isAssignment(row.status)"
|
||||
>
|
||||
<Badge :theme="getStatusTheme(row[column.key])">
|
||||
{{ row[column.key] }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
</ListView>
|
||||
|
||||
<!-- Courses -->
|
||||
<ListView
|
||||
:columns="courseColumns"
|
||||
:rows="studentDetails.data.courses"
|
||||
row-key="title"
|
||||
class="border border-outline-gray-modals rounded-lg"
|
||||
:options="{
|
||||
selectable: false,
|
||||
showTooltip: false,
|
||||
onRowClick: (row: any) => {
|
||||
redirectToCourse(row)
|
||||
}
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
|
||||
>
|
||||
</ListHeader>
|
||||
<ListRows v-for="row in studentDetails.data.courses">
|
||||
<ListRow :row="row" class="!rounded-none">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem
|
||||
:item="row[column.key]"
|
||||
:align="column.align"
|
||||
class="w-full"
|
||||
>
|
||||
<template #prefix>
|
||||
<ProgressBar
|
||||
v-if="column.key == 'progress'"
|
||||
:progress="Math.ceil(row[column.key])"
|
||||
class="!mx-0 !mr-4 max-w-32"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
v-if="column.key == 'progress'"
|
||||
class="text-xs !ml-0 !mr-3 w-5"
|
||||
>
|
||||
{{ Math.ceil(row[column.key]) }}%
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
</ListView>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
createResource,
|
||||
Dialog,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
} from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
|
||||
const show = defineModel()
|
||||
const router = useRouter()
|
||||
const props = defineProps<{
|
||||
student: string
|
||||
batch: string
|
||||
}>()
|
||||
|
||||
const studentDetails = createResource({
|
||||
url: 'lms.lms.utils.get_batch_student_progress',
|
||||
makeParams() {
|
||||
return {
|
||||
member: props.student,
|
||||
batch: props.batch,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const redirectToAssessment = (row: any) => {
|
||||
console.log(row)
|
||||
if (!row.submission) return
|
||||
if (row.type == 'LMS Assignment') {
|
||||
router.push({
|
||||
name: 'AssignmentSubmission',
|
||||
params: {
|
||||
assignmentID: row.assessment,
|
||||
submissionName: row.submission,
|
||||
},
|
||||
})
|
||||
} else if (row.type == 'LMS Programming Exercise') {
|
||||
router.push({
|
||||
name: 'ProgrammingExerciseSubmission',
|
||||
params: {
|
||||
exerciseID: row.assessment,
|
||||
submissionID: row.submission,
|
||||
},
|
||||
})
|
||||
} else if (row.type == 'LMS Quiz') {
|
||||
router.push({
|
||||
name: 'QuizSubmission',
|
||||
params: {
|
||||
submission: row.submission,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const redirectToCourse = (row: any) => {
|
||||
router.push({
|
||||
name: 'CourseDetail',
|
||||
params: {
|
||||
courseName: row.course,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const assessmentColumns = [
|
||||
{ key: 'title', label: 'Assessment', align: 'left', width: '60%' },
|
||||
{ key: 'status', label: 'Percentage/Status', align: 'right' },
|
||||
]
|
||||
|
||||
const courseColumns = [
|
||||
{ key: 'title', label: 'Course', align: 'left', width: '70%' },
|
||||
{ key: 'progress', label: 'Progress', align: 'right' },
|
||||
]
|
||||
|
||||
const isAssignment = (value: any) => {
|
||||
return isNaN(value)
|
||||
}
|
||||
|
||||
const getStatusTheme = (status: string) => {
|
||||
if (status === 'Pass') {
|
||||
return 'green'
|
||||
} else if (status == 'Not Graded') {
|
||||
return 'orange'
|
||||
} else {
|
||||
return 'red'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<div
|
||||
v-if="isAdmin() && !hasProviderAccount()"
|
||||
class="flex lg:items-center space-x-2 mb-5 bg-surface-amber-1 px-3 py-2 rounded-lg text-ink-amber-3"
|
||||
>
|
||||
<AlertCircle class="size-7 md:size-4 stroke-1.5" />
|
||||
<span class="leading-5">
|
||||
{{
|
||||
__(
|
||||
'Please select a conferencing provider and add an account to the batch to create live classes.'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Live Class') }}
|
||||
</div>
|
||||
<Button v-if="canCreateClass()" @click="openLiveClassModal">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Add') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="liveClasses.data?.length"
|
||||
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5 mt-5"
|
||||
>
|
||||
<div
|
||||
v-for="cls in liveClasses.data"
|
||||
class="flex flex-col border rounded-md h-full text-ink-gray-7 hover:border-outline-gray-3 p-3"
|
||||
:class="{
|
||||
'cursor-pointer': isAdmin() && cls.attendees > 0,
|
||||
}"
|
||||
@click="
|
||||
() => {
|
||||
openAttendanceModal(cls)
|
||||
}
|
||||
"
|
||||
>
|
||||
<div class="font-semibold text-ink-gray-9 mb-1">
|
||||
{{ cls.title }}
|
||||
</div>
|
||||
<div class="short-introduction">
|
||||
{{ cls.description }}
|
||||
</div>
|
||||
<div class="mt-auto space-y-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Clock class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ dayjs(getClassStart(cls)).format('hh:mm A') }} -
|
||||
{{ dayjs(getClassEnd(cls)).format('hh:mm A') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="canAccessClass(cls) && cls.join_url"
|
||||
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
|
||||
>
|
||||
<a
|
||||
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
||||
:href="cls.start_url || cls.join_url"
|
||||
target="_blank"
|
||||
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||
:class="cls.join_url ? 'w-full' : 'w-1/2'"
|
||||
>
|
||||
<Monitor class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Start') }}
|
||||
</a>
|
||||
<a
|
||||
:href="cls.join_url"
|
||||
target="_blank"
|
||||
class="w-full cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||
>
|
||||
<Video class="h-4 w-4 stroke-1.5" />
|
||||
{{ __('Join') }}
|
||||
</a>
|
||||
</div>
|
||||
<Tooltip
|
||||
v-else-if="hasClassEnded(cls)"
|
||||
:text="__('This class has ended')"
|
||||
placement="right"
|
||||
>
|
||||
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
|
||||
<Info class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Ended') }}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-ink-gray-7 mt-5">
|
||||
{{ __('No live classes scheduled') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LiveClassModal
|
||||
v-if="showLiveClassModal"
|
||||
v-model="showLiveClassModal"
|
||||
:batch="batch.data?.name"
|
||||
:zoomAccount="batch.data?.zoom_account"
|
||||
:googleMeetAccount="batch.data?.google_meet_account"
|
||||
:conferencingProvider="batch.data?.conferencing_provider"
|
||||
v-model:reloadLiveClasses="liveClasses"
|
||||
/>
|
||||
|
||||
<LiveClassAttendance
|
||||
v-if="showAttendance"
|
||||
v-model="showAttendance"
|
||||
:live_class="attendanceFor"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createListResource, Button, Tooltip } from 'frappe-ui'
|
||||
import {
|
||||
Plus,
|
||||
Clock,
|
||||
Calendar,
|
||||
Video,
|
||||
Monitor,
|
||||
Info,
|
||||
AlertCircle,
|
||||
} from 'lucide-vue-next'
|
||||
import { inject, ref } from 'vue'
|
||||
import { formatTime } from '@/utils/'
|
||||
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
|
||||
import LiveClassAttendance from '@/components/Modals/LiveClassAttendance.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const showLiveClassModal = ref(false)
|
||||
const dayjs = inject('$dayjs')
|
||||
const readOnlyMode = window.read_only_mode
|
||||
const showAttendance = ref(false)
|
||||
const attendanceFor = ref(null)
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const liveClasses = createListResource({
|
||||
doctype: 'LMS Live Class',
|
||||
filters: {
|
||||
batch_name: props.batch.data?.name,
|
||||
},
|
||||
fields: [
|
||||
'title',
|
||||
'description',
|
||||
'time',
|
||||
'date',
|
||||
'duration',
|
||||
'attendees',
|
||||
'start_url',
|
||||
'join_url',
|
||||
'owner',
|
||||
'conferencing_provider',
|
||||
'batch_name',
|
||||
],
|
||||
orderBy: 'date',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const openLiveClassModal = () => {
|
||||
showLiveClassModal.value = true
|
||||
}
|
||||
|
||||
const hasProviderAccount = () => {
|
||||
const data = props.batch.data
|
||||
if (data?.conferencing_provider === 'Zoom' && data?.zoom_account) return true
|
||||
if (
|
||||
data?.conferencing_provider === 'Google Meet' &&
|
||||
data?.google_meet_account
|
||||
)
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
const canCreateClass = () => {
|
||||
if (readOnlyMode) return false
|
||||
if (!hasProviderAccount()) return false
|
||||
return isAdmin()
|
||||
}
|
||||
|
||||
const isAdmin = () => {
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
}
|
||||
|
||||
const canAccessClass = (cls) => {
|
||||
if (cls.date < dayjs().format('YYYY-MM-DD')) return false
|
||||
if (cls.date > dayjs().format('YYYY-MM-DD')) return false
|
||||
if (hasClassEnded(cls)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const getClassStart = (cls) => {
|
||||
return new Date(`${cls.date}T${cls.time}`)
|
||||
}
|
||||
|
||||
const getClassEnd = (cls) => {
|
||||
const classStart = getClassStart(cls)
|
||||
return new Date(classStart.getTime() + cls.duration * 60000)
|
||||
}
|
||||
|
||||
const hasClassEnded = (cls) => {
|
||||
const classEnd = getClassEnd(cls)
|
||||
const now = new Date()
|
||||
return now > classEnd
|
||||
}
|
||||
|
||||
const openAttendanceModal = (cls) => {
|
||||
if (!isAdmin()) return
|
||||
if (cls.attendees <= 0) return
|
||||
attendanceFor.value = cls
|
||||
showAttendance.value = true
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.short-introduction {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0.25rem 0 1.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,268 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('New Batch'),
|
||||
size: '3xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="text-base">
|
||||
<div class="grid grid-cols-3 gap-5">
|
||||
<FormControl
|
||||
v-model="batch.title"
|
||||
:label="__('Title')"
|
||||
:required="true"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.start_date"
|
||||
:label="__('Start Date')"
|
||||
type="date"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.end_date"
|
||||
:label="__('End Date')"
|
||||
type="date"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.start_time"
|
||||
:label="__('Start Time')"
|
||||
type="time"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.end_time"
|
||||
:label="__('End Time')"
|
||||
type="time"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.timezone"
|
||||
:label="__('Timezone')"
|
||||
:required="true"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Link
|
||||
v-model="batch.category"
|
||||
doctype="LMS Category"
|
||||
:label="__('Category')"
|
||||
:onCreate="createCategory"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.seat_count"
|
||||
:label="__('Seat Count')"
|
||||
type="number"
|
||||
:required="false"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.medium"
|
||||
type="select"
|
||||
:options="mediumOptions"
|
||||
:label="__('Medium')"
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5 border-t mt-5 pt-5">
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<FormControl
|
||||
v-model="batch.description"
|
||||
:label="__('Description')"
|
||||
type="textarea"
|
||||
:required="true"
|
||||
:rows="4"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="batch.instructors"
|
||||
doctype="User"
|
||||
:label="__('Instructors')"
|
||||
:required="true"
|
||||
:onCreate="() => (showMemberModal = true)"
|
||||
url="lms.lms.api.search_users_by_role"
|
||||
:searchParams="{ roles: JSON.stringify(['Batch Evaluator']) }"
|
||||
/>
|
||||
</div>
|
||||
<div class="">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Batch Details') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<TextEditor
|
||||
:content="batch.batch_details"
|
||||
@change="(val: string) => (batch.batch_details = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[10rem] max-h-[14rem] overflow-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="text-right">
|
||||
<Button variant="solid" @click="saveBatch(close)">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
<NewMemberModal
|
||||
v-model="showMemberModal"
|
||||
:defaultRoles="['batch_evaluator']"
|
||||
@created="onInstructorCreated"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import { computed, inject, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { sanitizeHTML, escapeHTML, createLMSCategory } from '@/utils'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import NewMemberModal from '@/components/Modals/NewMemberModal.vue'
|
||||
|
||||
const show = defineModel<boolean>({ required: true, default: false })
|
||||
const router = useRouter()
|
||||
const { capture } = useTelemetry()
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
const user = inject<any>('$user')
|
||||
const showMemberModal = ref(false)
|
||||
|
||||
const props = defineProps<{
|
||||
batches: any
|
||||
}>()
|
||||
|
||||
type Batch = {
|
||||
title: string
|
||||
start_date: string | null
|
||||
end_date: string | null
|
||||
start_time: string | null
|
||||
end_time: string | null
|
||||
timezone: string | null
|
||||
description: string
|
||||
batch_details: string
|
||||
instructors: string[]
|
||||
category: string | null
|
||||
seat_count: number
|
||||
medium: string | null
|
||||
}
|
||||
|
||||
const batch = ref<Batch>({
|
||||
title: '',
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
timezone: null,
|
||||
description: '',
|
||||
batch_details: '',
|
||||
instructors: [],
|
||||
category: null,
|
||||
seat_count: 0,
|
||||
medium: null,
|
||||
})
|
||||
|
||||
const createCategory = (name: string, done: () => void) => {
|
||||
createLMSCategory(name).then((categoryName: string) => {
|
||||
if (!categoryName) return
|
||||
batch.value.category = categoryName
|
||||
done()
|
||||
})
|
||||
}
|
||||
|
||||
const onInstructorCreated = (user: any) => {
|
||||
batch.value.instructors = [...batch.value.instructors, user.name]
|
||||
}
|
||||
|
||||
const validateFields = () => {
|
||||
batch.value.description = sanitizeHTML(batch.value.description)
|
||||
batch.value.batch_details = sanitizeHTML(batch.value.batch_details)
|
||||
|
||||
Object.keys(batch.value).forEach((key) => {
|
||||
if (
|
||||
key != 'description' &&
|
||||
key != 'batch_details' &&
|
||||
typeof batch.value[key as keyof Batch] === 'string'
|
||||
) {
|
||||
batch.value[key as keyof Batch] = escapeHTML(
|
||||
batch.value[key as keyof Batch] as string
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const saveBatch = (close: () => void = () => {}) => {
|
||||
validateFields()
|
||||
props.batches.insert.submit(
|
||||
{
|
||||
...batch.value,
|
||||
instructors: batch.value.instructors.map((instructor) => ({
|
||||
instructor: instructor,
|
||||
})),
|
||||
},
|
||||
{
|
||||
onSuccess(data: any) {
|
||||
toast.success(__('Batch created successfully'))
|
||||
close()
|
||||
capture('batch_created')
|
||||
router.push({
|
||||
name: 'BatchDetail',
|
||||
params: { batchName: data.name },
|
||||
hash: '#settings',
|
||||
})
|
||||
if (user.data?.is_system_manager) {
|
||||
updateOnboardingStep('create_first_batch', true, false, () => {
|
||||
localStorage.setItem('firstBatch', data.name)
|
||||
})
|
||||
}
|
||||
},
|
||||
onError(err: any) {
|
||||
toast.error(cleanError(err.messages?.[0]))
|
||||
console.error(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const keyboardShortcut = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.key === 's' &&
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
e.target &&
|
||||
e.target instanceof HTMLElement &&
|
||||
!e.target.classList.contains('ProseMirror')
|
||||
) {
|
||||
saveBatch()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', keyboardShortcut)
|
||||
capture('batch_form_opened')
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', keyboardShortcut)
|
||||
capture('batch_form_closed', {
|
||||
data: batch.value,
|
||||
})
|
||||
})
|
||||
|
||||
const mediumOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Online'),
|
||||
value: 'Online',
|
||||
},
|
||||
{
|
||||
label: __('Offline'),
|
||||
value: 'Offline',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -114,25 +114,27 @@
|
||||
<FormControl
|
||||
:label="__('Billing Name')"
|
||||
v-model="billingDetails.billing_name"
|
||||
:required="true"
|
||||
:required="!!fieldMeta.billing_name?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Address Line 1')"
|
||||
v-model="billingDetails.address_line1"
|
||||
:required="true"
|
||||
:required="!!fieldMeta.address_line1?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Address Line 2')"
|
||||
v-model="billingDetails.address_line2"
|
||||
:required="!!fieldMeta.address_line2?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('City')"
|
||||
v-model="billingDetails.city"
|
||||
:required="true"
|
||||
:required="!!fieldMeta.city?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('State/Province')"
|
||||
v-model="billingDetails.state"
|
||||
:required="!!fieldMeta.state?.reqd"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
@@ -141,34 +143,36 @@
|
||||
:value="billingDetails.country"
|
||||
@change="(option) => changeCurrency(option)"
|
||||
:label="__('Country')"
|
||||
:required="true"
|
||||
:required="!!fieldMeta.country?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Postal Code')"
|
||||
v-model="billingDetails.pincode"
|
||||
:required="true"
|
||||
:required="!!fieldMeta.pincode?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Phone Number')"
|
||||
v-model="billingDetails.phone"
|
||||
:required="true"
|
||||
:required="!!fieldMeta.phone?.reqd"
|
||||
/>
|
||||
<Link
|
||||
doctype="LMS Source"
|
||||
:value="billingDetails.source"
|
||||
@change="(option) => (billingDetails.source = option)"
|
||||
:label="__('Where did you hear about us?')"
|
||||
:required="true"
|
||||
:required="!!fieldMeta.source?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="billingDetails.country == 'India'"
|
||||
:label="__('GST Number')"
|
||||
v-model="billingDetails.gstin"
|
||||
:required="!!fieldMeta.gstin?.reqd"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="billingDetails.country == 'India'"
|
||||
:label="__('PAN Number')"
|
||||
v-model="billingDetails.pan"
|
||||
:required="!!fieldMeta.pan?.reqd"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -273,6 +277,7 @@ const access = createResource({
|
||||
name: props.name,
|
||||
},
|
||||
onSuccess(data) {
|
||||
Object.assign(fieldMeta, data.billing_field_meta || {})
|
||||
setBillingDetails(data.address)
|
||||
orderSummary.submit()
|
||||
},
|
||||
@@ -295,19 +300,24 @@ const orderSummary = createResource({
|
||||
|
||||
const appliedCoupon = ref(null)
|
||||
const billingDetails = reactive({})
|
||||
const fieldMeta = reactive({})
|
||||
|
||||
const getDefault = (fieldname) => fieldMeta[fieldname]?.default || ''
|
||||
|
||||
const setBillingDetails = (data) => {
|
||||
billingDetails.billing_name = data?.billing_name || ''
|
||||
billingDetails.address_line1 = data?.address_line1 || ''
|
||||
billingDetails.address_line2 = data?.address_line2 || ''
|
||||
billingDetails.city = data?.city || ''
|
||||
billingDetails.state = data?.state || ''
|
||||
billingDetails.country = data?.country || ''
|
||||
billingDetails.pincode = data?.pincode || ''
|
||||
billingDetails.phone = data?.phone || ''
|
||||
billingDetails.source = data?.source || ''
|
||||
billingDetails.gstin = data?.gstin || ''
|
||||
billingDetails.pan = data?.pan || ''
|
||||
billingDetails.billing_name = data?.billing_name || getDefault('billing_name')
|
||||
billingDetails.address_line1 =
|
||||
data?.address_line1 || getDefault('address_line1')
|
||||
billingDetails.address_line2 =
|
||||
data?.address_line2 || getDefault('address_line2')
|
||||
billingDetails.city = data?.city || getDefault('city')
|
||||
billingDetails.state = data?.state || getDefault('state')
|
||||
billingDetails.country = data?.country || getDefault('country')
|
||||
billingDetails.pincode = data?.pincode || getDefault('pincode')
|
||||
billingDetails.phone = data?.phone || getDefault('phone')
|
||||
billingDetails.source = data?.source || getDefault('source')
|
||||
billingDetails.gstin = data?.gstin || getDefault('gstin')
|
||||
billingDetails.pan = data?.pan || getDefault('pan')
|
||||
}
|
||||
|
||||
const paymentLink = createResource({
|
||||
@@ -336,7 +346,7 @@ const generatePaymentLink = () => {
|
||||
{},
|
||||
{
|
||||
validate() {
|
||||
if (!billingDetails.source) {
|
||||
if (!billingDetails.source && fieldMeta.source?.reqd) {
|
||||
return __('Please let us know where you heard about us from.')
|
||||
}
|
||||
if (!billingDetails.member_consent) {
|
||||
@@ -370,15 +380,19 @@ function removeCoupon() {
|
||||
}
|
||||
|
||||
const validateAddress = () => {
|
||||
let mandatoryFields = [
|
||||
let billingFields = [
|
||||
'billing_name',
|
||||
'address_line1',
|
||||
'address_line2',
|
||||
'city',
|
||||
'state',
|
||||
'pincode',
|
||||
'country',
|
||||
'phone',
|
||||
'source',
|
||||
'gstin',
|
||||
'pan',
|
||||
]
|
||||
let mandatoryFields = billingFields.filter((f) => fieldMeta[f]?.reqd)
|
||||
for (let field of mandatoryFields) {
|
||||
if (!billingDetails[field])
|
||||
return (
|
||||
|
||||
@@ -41,16 +41,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="openToWork"
|
||||
:label="__('Open to Work')"
|
||||
type="checkbox"
|
||||
@change="updateParticipants()"
|
||||
/>
|
||||
<FormControl
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="hiring"
|
||||
:label="__('Hiring')"
|
||||
type="checkbox"
|
||||
@change="updateParticipants()"
|
||||
/>
|
||||
</div>
|
||||
@@ -129,6 +129,7 @@ import {
|
||||
createListResource,
|
||||
FormControl,
|
||||
Select,
|
||||
Switch,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<div class="grid grid-cols-4 gap-5 mb-5">
|
||||
<div class="grid grid-cols-4 gap-5 mb-5 text-ink-gray-9">
|
||||
<NumberChartGraph
|
||||
:title="__('Enrolled')"
|
||||
:value="formatAmount(course.data?.enrollments)"
|
||||
@@ -20,9 +20,9 @@
|
||||
<NumberChartGraph :title="__('Lessons')" :value="course.data?.lessons" />
|
||||
</div>
|
||||
<div class="grid grid-cols-[2fr_1fr] gap-5 items-start">
|
||||
<div v-if="course.data?.enrollments" class="border rounded-lg py-3 px-4">
|
||||
<div class="border rounded-lg py-3 px-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="text-lg font-semibold">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||
{{ __('Students') }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
@@ -63,50 +63,52 @@
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows v-for="row in progressList.data" class="max-h-[500px]">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: { username: row.member_username },
|
||||
}"
|
||||
<ListRow
|
||||
:row="row"
|
||||
@click="
|
||||
() => {
|
||||
showProgressModal = true
|
||||
currentStudent = row
|
||||
}
|
||||
"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<ListRow :row="row">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem
|
||||
:item="row[column.key]"
|
||||
:align="column.align"
|
||||
class="w-full"
|
||||
>
|
||||
<template #prefix>
|
||||
<div v-if="column.key == 'member_name'">
|
||||
<Avatar
|
||||
class="flex items-center"
|
||||
:image="row['member_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<ProgressBar
|
||||
v-else-if="column.key == 'progress'"
|
||||
:progress="Math.ceil(row[column.key])"
|
||||
class="!mx-0 !mr-4"
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem
|
||||
:item="row[column.key]"
|
||||
:align="column.align"
|
||||
class="w-full"
|
||||
>
|
||||
<template #prefix>
|
||||
<div v-if="column.key == 'member_name'">
|
||||
<Avatar
|
||||
class="flex items-center"
|
||||
:image="row['member_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
/>
|
||||
</template>
|
||||
<div v-if="column.key == 'creation'">
|
||||
{{ dayjs(row[column.key]).format('DD MMM YYYY') }}
|
||||
</div>
|
||||
<div
|
||||
<ProgressBar
|
||||
v-else-if="column.key == 'progress'"
|
||||
class="text-xs !mx-0 w-5"
|
||||
>
|
||||
{{ Math.ceil(row[column.key]) }}%
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key].toString() }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</router-link>
|
||||
:progress="Math.ceil(row[column.key])"
|
||||
class="!mx-0 !mr-4"
|
||||
/>
|
||||
</template>
|
||||
<div v-if="column.key == 'creation'">
|
||||
{{ dayjs(row[column.key]).format('DD MMM YYYY') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="column.key == 'progress'"
|
||||
class="text-xs !mx-0 w-5"
|
||||
>
|
||||
{{ Math.ceil(row[column.key]) }}%
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ row[column.key].toString() }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
</ListView>
|
||||
<div
|
||||
@@ -127,10 +129,12 @@
|
||||
<div class="text-ink-gray-5 mb-4">
|
||||
{{ __('Progress Summary') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-[2fr_1fr] items-center justify-between">
|
||||
<div
|
||||
class="grid grid-cols-[2fr_1fr] items-center justify-between text-ink-gray-9"
|
||||
>
|
||||
<div class="flex flex-col space-y-4 flex-1 text-sm">
|
||||
<div
|
||||
class="flex items-center"
|
||||
class="flex items-center text-ink-gray-7"
|
||||
v-for="row in chartDetails.data?.progress_distribution"
|
||||
>
|
||||
<div
|
||||
@@ -142,6 +146,8 @@
|
||||
? 'red'
|
||||
: row.name.startsWith('In')
|
||||
? 'amber'
|
||||
: row.name.startsWith('Adv')
|
||||
? 'blue'
|
||||
: 'green'
|
||||
][400],
|
||||
}"
|
||||
@@ -151,11 +157,13 @@
|
||||
{{ row.name.split('(')[0] }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div class="ml-auto">
|
||||
{{
|
||||
Math.round((row.value / course.data?.enrollments) * 100)
|
||||
}}%
|
||||
</div>
|
||||
<Tooltip :text="row.value">
|
||||
<div class="ml-auto">
|
||||
{{
|
||||
Math.round((row.value / course.data?.enrollments) * 100)
|
||||
}}%
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<ECharts
|
||||
@@ -205,10 +213,12 @@
|
||||
class="!w-32"
|
||||
/>
|
||||
</div>
|
||||
<div class="divide-y max-h-[43vh] overflow-y-auto">
|
||||
<div
|
||||
class="divide-y max-h-[40vh] divide-outline-gray-modals text-ink-gray-7 overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
v-for="progress in lessonProgress.data"
|
||||
class="flex justify-between text-sm py-2 my-1"
|
||||
class="flex justify-between text-sm py-2 my-1 text-ink-gray-9"
|
||||
>
|
||||
<div class="">
|
||||
<span class="mr-3 text-xs">
|
||||
@@ -238,6 +248,14 @@
|
||||
v-if="showEnrollmentModal"
|
||||
v-model="showEnrollmentModal"
|
||||
:course="course"
|
||||
:students="progressList"
|
||||
/>
|
||||
<StudentCourseProgress
|
||||
v-if="showProgressModal"
|
||||
v-model="showProgressModal"
|
||||
:course="course"
|
||||
:student="currentStudent"
|
||||
:lessons="lessonProgress"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
@@ -260,12 +278,13 @@ import {
|
||||
Tooltip,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ChevronDown, Plus, Star } from 'lucide-vue-next'
|
||||
import { Plus, Star } from 'lucide-vue-next'
|
||||
import { formatAmount } from '@/utils'
|
||||
import colors from '@/utils/frappe-ui-colors.json'
|
||||
import CourseEnrollmentModal from '@/pages/Courses/CourseEnrollmentModal.vue'
|
||||
import NumberChartGraph from '@/components/NumberChartGraph.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
import StudentCourseProgress from '@/pages/Courses/StudentCourseProgress.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
course: any
|
||||
@@ -273,6 +292,8 @@ const props = defineProps<{
|
||||
|
||||
const showEnrollmentModal = ref(false)
|
||||
const searchFilter = ref<string | null>(null)
|
||||
const showProgressModal = ref(false)
|
||||
const currentStudent = ref<any>(null)
|
||||
const theme = ref<'darkMode' | 'lightMode'>(
|
||||
localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
|
||||
)
|
||||
@@ -307,6 +328,7 @@ const progressList = createListResource({
|
||||
],
|
||||
pageLength: 100,
|
||||
auto: true,
|
||||
cache: ['courseProgress', props.course.data?.name],
|
||||
})
|
||||
|
||||
const lessonProgress = createResource({
|
||||
@@ -332,14 +354,12 @@ const updateLessonProgress = (value: string) => {
|
||||
}
|
||||
|
||||
watch([searchFilter], () => {
|
||||
let filterApplied = false
|
||||
let filters: Filters = {
|
||||
course: props.course.data?.name,
|
||||
}
|
||||
|
||||
if (searchFilter.value) {
|
||||
filters.member_name = ['like', `%${searchFilter.value}%`]
|
||||
filterApplied = true
|
||||
}
|
||||
|
||||
progressList.update({
|
||||
@@ -357,6 +377,7 @@ const progressColors = computed(() => {
|
||||
let colorList = []
|
||||
colorList.push(colors[theme.value]['red'][400])
|
||||
colorList.push(colors[theme.value]['amber'][400])
|
||||
colorList.push(colors[theme.value]['blue'][400])
|
||||
colorList.push(colors[theme.value]['green'][400])
|
||||
return colorList
|
||||
})
|
||||
@@ -374,7 +395,7 @@ const progressColumns = computed(() => {
|
||||
width: '30%',
|
||||
},
|
||||
{
|
||||
label: __('Start Date'),
|
||||
label: __('Enrolled On'),
|
||||
key: 'creation',
|
||||
align: 'right',
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user