Compare commits
558 Commits
v2.28.1
...
pot_develo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b69ddb14d | ||
|
|
2271eb270e | ||
|
|
7e5b2e4e79 | ||
|
|
36076068ec | ||
|
|
c868354b5b | ||
|
|
db91f0b2a0 | ||
|
|
d7e83bb78e | ||
|
|
feb2a39e05 | ||
|
|
a6cf910d05 | ||
|
|
b891b44ac6 | ||
|
|
026a3ebb81 | ||
|
|
71ba246011 | ||
|
|
a391204fa6 | ||
|
|
9c773399a8 | ||
|
|
528b85352a | ||
|
|
249c369c14 | ||
|
|
9803fc1031 | ||
|
|
299fde1c98 | ||
|
|
7f55734fbb | ||
|
|
efe230865a | ||
|
|
6e52e684c8 | ||
|
|
99d880297a | ||
|
|
dec706ae72 | ||
|
|
2e60f0a0c2 | ||
|
|
ef612f86e5 | ||
|
|
9c16e03ea7 | ||
|
|
7780c0310e | ||
|
|
b0a23c0d1a | ||
|
|
05c85cea08 | ||
|
|
1ffae0a1de | ||
|
|
15cbccd15f | ||
|
|
266b2f2ac8 | ||
|
|
26f9fb4199 | ||
|
|
67887fb6ef | ||
|
|
3d102e39ff | ||
|
|
ddd9089130 | ||
|
|
d8ce88ab57 | ||
|
|
01794a47c6 | ||
|
|
17626dbbdb | ||
|
|
e5bd86658d | ||
|
|
e911dc1353 | ||
|
|
27e3e5aa6a | ||
|
|
5b65525bf1 | ||
|
|
277804f8b1 | ||
|
|
4c77802e3c | ||
|
|
aacfea6ea5 | ||
|
|
6d55040e43 | ||
|
|
290f785a47 | ||
|
|
39ef187f6b | ||
|
|
a7a475e763 | ||
|
|
6eb380ea38 | ||
|
|
4d150cb323 | ||
|
|
09d6d99b14 | ||
|
|
5e7fd8baff | ||
|
|
52c159e2e8 | ||
|
|
67e8feb879 | ||
|
|
a5b61d5244 | ||
|
|
decc3a16ed | ||
|
|
7f39e9f0cc | ||
|
|
95afa1a6ad | ||
|
|
0d0bb5f9e2 | ||
|
|
3dd5ce5035 | ||
|
|
549e56d551 | ||
|
|
50b6215d1e | ||
|
|
ff69bfdce7 | ||
|
|
c04cc8ec0f | ||
|
|
f324de2254 | ||
|
|
40af4e6f34 | ||
|
|
5d9b66b5cb | ||
|
|
d2a8277c13 | ||
|
|
ada85fc0f3 | ||
|
|
505345eff7 | ||
|
|
2911ade880 | ||
|
|
8980dc8f9c | ||
|
|
d94a1c47c0 | ||
|
|
99c3e5182d | ||
|
|
70e39fee40 | ||
|
|
26d6bec8a0 | ||
|
|
c9ac1a1402 | ||
|
|
6949c1092c | ||
|
|
aae8a54481 | ||
|
|
e1d93bf670 | ||
|
|
fea0533cb1 | ||
|
|
5cd991f02a | ||
|
|
50a8a605d5 | ||
|
|
9ce7d8f5d6 | ||
|
|
eae2587e4c | ||
|
|
323097f201 | ||
|
|
014499888a | ||
|
|
5662de21ae | ||
|
|
17c2eba455 | ||
|
|
1f2c986e8f | ||
|
|
12040b5f6d | ||
|
|
20a985848f | ||
|
|
c06c6169e5 | ||
|
|
917aeb79ef | ||
|
|
c4f36a39fe | ||
|
|
befedc30ad | ||
|
|
d3bc67daa2 | ||
|
|
5d7e211367 | ||
|
|
fa9daa01ec | ||
|
|
0ed9dc63b8 | ||
|
|
5dd6b33eb2 | ||
|
|
1210b823c7 | ||
|
|
04240b4b3d | ||
|
|
787f592a1a | ||
|
|
e7363fbd40 | ||
|
|
e2762825e5 | ||
|
|
bbbca70c71 | ||
|
|
8dde423866 | ||
|
|
fc4c1c2b7e | ||
|
|
bf02e2de3f | ||
|
|
a26ba4dc6e | ||
|
|
f187cc9314 | ||
|
|
c15c6374f9 | ||
|
|
acec382dfe | ||
|
|
fbc078c6b6 | ||
|
|
170b20185a | ||
|
|
3e8489c13b | ||
|
|
18dfc4c23e | ||
|
|
e6bae3dc77 | ||
|
|
6f9f27c030 | ||
|
|
874bef74c7 | ||
|
|
ad483e0916 | ||
|
|
5b4bbaec20 | ||
|
|
b8ae0db0bd | ||
|
|
f2c18fad52 | ||
|
|
9716655b94 | ||
|
|
efb317191c | ||
|
|
a47b5db40c | ||
|
|
ec94796b9c | ||
|
|
e3e0cd61a2 | ||
|
|
a438473279 | ||
|
|
12b5b8b509 | ||
|
|
22442b47a8 | ||
|
|
30c8b7d64f | ||
|
|
b643575c4f | ||
|
|
7dd7124fac | ||
|
|
4b1eebf5bb | ||
|
|
3257943926 | ||
|
|
24246c83e0 | ||
|
|
a26787f478 | ||
|
|
ec3b88f890 | ||
|
|
7f5f1dad92 | ||
|
|
b6db128214 | ||
|
|
8831635db2 | ||
|
|
e19198b720 | ||
|
|
f618d9dc1a | ||
|
|
66a667a0a3 | ||
|
|
8a4c67f712 | ||
|
|
fa6ef2e989 | ||
|
|
7450b99197 | ||
|
|
023fd272b1 | ||
|
|
84067cb027 | ||
|
|
3087ef70e7 | ||
|
|
387385bb1c | ||
|
|
6766d0d08c | ||
|
|
371d890793 | ||
|
|
57046c1b38 | ||
|
|
2a64144e94 | ||
|
|
9b0320ccf1 | ||
|
|
23f209131e | ||
|
|
d71f1c7f9a | ||
|
|
d21ea2c854 | ||
|
|
cd7f3ba820 | ||
|
|
e057d3ed9a | ||
|
|
5f04607a44 | ||
|
|
9440d13a08 | ||
|
|
85c4f1654e | ||
|
|
eed339cc64 | ||
|
|
3d1a23576a | ||
|
|
ed0e2e4bb5 | ||
|
|
954d0a0637 | ||
|
|
f2c8788602 | ||
|
|
49c63da27c | ||
|
|
24496d1856 | ||
|
|
991ebe09a2 | ||
|
|
85da4f6d85 | ||
|
|
5f065db991 | ||
|
|
ffb40586d7 | ||
|
|
fcfd87fd50 | ||
|
|
eb5b12aa7b | ||
|
|
f6e2438744 | ||
|
|
e3c7dc695d | ||
|
|
82d2025e6c | ||
|
|
91b82d78b8 | ||
|
|
b97e792893 | ||
|
|
13ac5ec7dc | ||
|
|
199f880936 | ||
|
|
ed86c207ba | ||
|
|
b4cf290f4d | ||
|
|
e526a6fd64 | ||
|
|
94cbbf169a | ||
|
|
2837ed16a7 | ||
|
|
68961deb6b | ||
|
|
ec54bfee98 | ||
|
|
385e97b76a | ||
|
|
cbd916877f | ||
|
|
38586034cd | ||
|
|
62b3ba2bff | ||
|
|
dd470b61b5 | ||
|
|
4fa92d2327 | ||
|
|
6f6c2db66d | ||
|
|
e6348cfa20 | ||
|
|
a006d1000a | ||
|
|
4a575e642f | ||
|
|
93525bc577 | ||
|
|
2cf0e9a723 | ||
|
|
c32164bfea | ||
|
|
714b0924e7 | ||
|
|
43079790a8 | ||
|
|
d03e61b625 | ||
|
|
2d760112a3 | ||
|
|
f46507ec72 | ||
|
|
e9e10bdc93 | ||
|
|
0386967a32 | ||
|
|
4900fc8b88 | ||
|
|
99294b5643 | ||
|
|
eb12bcb83c | ||
|
|
22a2e57642 | ||
|
|
5eaae06ceb | ||
|
|
ce7fc35349 | ||
|
|
8d4b5c83ae | ||
|
|
cbd3c56ca0 | ||
|
|
be6dad1424 | ||
|
|
298452fa7b | ||
|
|
4abbd7c35c | ||
|
|
c2f51c51ab | ||
|
|
255cff6664 | ||
|
|
8a9578bb0a | ||
|
|
8831f6cecc | ||
|
|
f3daa7e48b | ||
|
|
6163597958 | ||
|
|
f9e1222065 | ||
|
|
7d85de7c6c | ||
|
|
cf452c2300 | ||
|
|
72bd1d548d | ||
|
|
4556f4dee6 | ||
|
|
3dfbd3165a | ||
|
|
02b8e02131 | ||
|
|
087ded9f9e | ||
|
|
21f122ee82 | ||
|
|
d60a7e8c94 | ||
|
|
b8981c249f | ||
|
|
e71275a0dc | ||
|
|
4fb0db7a1e | ||
|
|
1e9beedc77 | ||
|
|
4a4a0653ef | ||
|
|
c80a900277 | ||
|
|
6fb0394d96 | ||
|
|
a6a7712039 | ||
|
|
dd0687ba29 | ||
|
|
9cb87a5333 | ||
|
|
8ec93d84a0 | ||
|
|
1d38715db9 | ||
|
|
6225c4eb35 | ||
|
|
e58ce2fbe6 | ||
|
|
8881d62e78 | ||
|
|
effb2a1265 | ||
|
|
ab387473b5 | ||
|
|
3cf6079b70 | ||
|
|
53c655bb53 | ||
|
|
87952463c2 | ||
|
|
3a8a63a49a | ||
|
|
debe115044 | ||
|
|
554d2808fd | ||
|
|
12b2c89a25 | ||
|
|
a66fc3a07e | ||
|
|
7b3705cab0 | ||
|
|
8e99e5f5e8 | ||
|
|
c5ba5370bb | ||
|
|
464dec9810 | ||
|
|
c2e2ec8803 | ||
|
|
37378e2360 | ||
|
|
678385d90c | ||
|
|
4c461f087f | ||
|
|
88a2b69980 | ||
|
|
1f57792da7 | ||
|
|
9bb4c45a23 | ||
|
|
75fd19f491 | ||
|
|
0ac16bdeb7 | ||
|
|
223ee41e10 | ||
|
|
c126ded82e | ||
|
|
0edf78b7fd | ||
|
|
5af3580987 | ||
|
|
343cb6f97a | ||
|
|
023c8ac13e | ||
|
|
c385eed795 | ||
|
|
ee5fdd789f | ||
|
|
df1e400f4e | ||
|
|
6c9c298478 | ||
|
|
7106ee150d | ||
|
|
03e2287f80 | ||
|
|
2edcd41e24 | ||
|
|
0fe043bd99 | ||
|
|
6686f5240d | ||
|
|
2936facf0f | ||
|
|
cc208f2c43 | ||
|
|
9a0fc231e5 | ||
|
|
bfc0ae62ec | ||
|
|
5e7d8d97f2 | ||
|
|
70ceb16ed6 | ||
|
|
f162fa639f | ||
|
|
f000c72546 | ||
|
|
32c01f931c | ||
|
|
d0121e2b9d | ||
|
|
1caab8ce1d | ||
|
|
878be435a1 | ||
|
|
6a68ae989e | ||
|
|
00993da781 | ||
|
|
e9ef67e402 | ||
|
|
83ebfececf | ||
|
|
ec8bf6251f | ||
|
|
1b2874b3a5 | ||
|
|
0ac1053a71 | ||
|
|
224d270952 | ||
|
|
c6137545cd | ||
|
|
335417f9f4 | ||
|
|
cb797223ed | ||
|
|
3a2a0313ac | ||
|
|
e221a5a73a | ||
|
|
2b7aaf095f | ||
|
|
6f01e7b8d8 | ||
|
|
d594419200 | ||
|
|
bf50e3f898 | ||
|
|
d434f1781f | ||
|
|
3f311a45ef | ||
|
|
9293b7796e | ||
|
|
b1e7883526 | ||
|
|
7fcf6a253d | ||
|
|
be8d985d15 | ||
|
|
974c90dddc | ||
|
|
4811d395d2 | ||
|
|
132423d577 | ||
|
|
10829e2f00 | ||
|
|
47b908c964 | ||
|
|
0f8e471d5d | ||
|
|
2537119250 | ||
|
|
977066d114 | ||
|
|
46e956dc74 | ||
|
|
7afdd8d44f | ||
|
|
6daf204b4f | ||
|
|
2f4a550a4a | ||
|
|
fe214f6b41 | ||
|
|
ca7de81888 | ||
|
|
17ce20355a | ||
|
|
34981b4765 | ||
|
|
21151a2e09 | ||
|
|
1abb7f5b8c | ||
|
|
05998549a4 | ||
|
|
96283a3629 | ||
|
|
2bfc7abe9c | ||
|
|
4f389eca8d | ||
|
|
1789479955 | ||
|
|
212800155b | ||
|
|
c241bf2104 | ||
|
|
bda61f32f3 | ||
|
|
59316dbaf9 | ||
|
|
b726073a5b | ||
|
|
adf897c812 | ||
|
|
1fc4c2442c | ||
|
|
414643ee90 | ||
|
|
1a1cbd6ea1 | ||
|
|
9ae809a62f | ||
|
|
eb9b1c905d | ||
|
|
fe9a8f49c1 | ||
|
|
f912c8fce3 | ||
|
|
1d1ca43c35 | ||
|
|
bce45f44e4 | ||
|
|
07583fb563 | ||
|
|
775aa23992 | ||
|
|
05ed6b7e73 | ||
|
|
d602694ea7 | ||
|
|
18d71bc0d4 | ||
|
|
3fa68643ba | ||
|
|
8904525c36 | ||
|
|
3ce09a98f3 | ||
|
|
b833768e71 | ||
|
|
b9a6afd993 | ||
|
|
b5a81ea927 | ||
|
|
750e92cdde | ||
|
|
da45f4c011 | ||
|
|
544bb5c11c | ||
|
|
1fc6f62f70 | ||
|
|
8751ad27ec | ||
|
|
159d3d5b87 | ||
|
|
34d6d99d8c | ||
|
|
6c46931b1a | ||
|
|
2c3e2d9d08 | ||
|
|
7be1562fa4 | ||
|
|
294389e7c7 | ||
|
|
2c8ce133f7 | ||
|
|
4f1d4d90d0 | ||
|
|
7b7484332b | ||
|
|
50e94b85aa | ||
|
|
9b820594ef | ||
|
|
ddcd45d56d | ||
|
|
c4a4c16516 | ||
|
|
5ae9ad0762 | ||
|
|
405f7d498e | ||
|
|
bcd6a5b1e7 | ||
|
|
e5e5ac994c | ||
|
|
e1f8d6ec49 | ||
|
|
6f50242f5a | ||
|
|
036f7ece05 | ||
|
|
622a2ff072 | ||
|
|
60334ca04a | ||
|
|
ade47b4e83 | ||
|
|
d7e550dfea | ||
|
|
c3cc0b9bf7 | ||
|
|
5ad89189c1 | ||
|
|
f1bbd4eb13 | ||
|
|
fba89dfacb | ||
|
|
b93ed41215 | ||
|
|
13ff6a7304 | ||
|
|
ad97405e55 | ||
|
|
376e231d7b | ||
|
|
e16d76f6dd | ||
|
|
ffd0fd92fc | ||
|
|
933613d730 | ||
|
|
9b0673bf92 | ||
|
|
7cba22aa28 | ||
|
|
af05b614a9 | ||
|
|
c0fa219a8b | ||
|
|
4e3a47b0f4 | ||
|
|
161276b58a | ||
|
|
47713019a5 | ||
|
|
010632a21d | ||
|
|
e77fe550af | ||
|
|
0a4233da14 | ||
|
|
56fb70ab1e | ||
|
|
4a1f2bc01d | ||
|
|
20292fbf16 | ||
|
|
1290cf8991 | ||
|
|
b8b8af7cf1 | ||
|
|
75f4f452d3 | ||
|
|
9de492384f | ||
|
|
14c4e161f2 | ||
|
|
c55efbc0ba | ||
|
|
f0610222d9 | ||
|
|
302ee4a50f | ||
|
|
2170819159 | ||
|
|
0d1fac321a | ||
|
|
dbbc1756dd | ||
|
|
d5b882d3f8 | ||
|
|
3025ea9a7b | ||
|
|
5dba4d1384 | ||
|
|
e4f1e7b093 | ||
|
|
d0a0597087 | ||
|
|
c9ccf9a1b5 | ||
|
|
69107d4441 | ||
|
|
e25afc1ef7 | ||
|
|
9babfd150e | ||
|
|
532dbbea4a | ||
|
|
0d284d05d9 | ||
|
|
28fccae3ac | ||
|
|
3a4a6da69c | ||
|
|
4ea07a95e7 | ||
|
|
80ceb49358 | ||
|
|
589337116a | ||
|
|
cb50067223 | ||
|
|
4d63266d88 | ||
|
|
90dd33ce21 | ||
|
|
763b849ddf | ||
|
|
9c76c54283 | ||
|
|
5cb17b3a36 | ||
|
|
2f7b5d1cbb | ||
|
|
4fe14eb2e9 | ||
|
|
eb089f2b58 | ||
|
|
4f0ac98eea | ||
|
|
af19940fa1 | ||
|
|
5635d2a325 | ||
|
|
5e2de35693 | ||
|
|
ef7180f23f | ||
|
|
f939973d4f | ||
|
|
63f327733e | ||
|
|
c1fb807fe4 | ||
|
|
b7ddf44267 | ||
|
|
6d4c72ea5e | ||
|
|
3db11b9372 | ||
|
|
b8714f4abe | ||
|
|
7ccbe74bbe | ||
|
|
ea3ae3516b | ||
|
|
d33af3ca52 | ||
|
|
291c3fa908 | ||
|
|
a51fa58122 | ||
|
|
65a3967abd | ||
|
|
e1e5c94a43 | ||
|
|
f15127eceb | ||
|
|
071a238b71 | ||
|
|
050b052156 | ||
|
|
8f65cca776 | ||
|
|
66624a8c47 | ||
|
|
c8b9a415e6 | ||
|
|
a1dcb4c203 | ||
|
|
d4edc3e622 | ||
|
|
e2b8c3ee0e | ||
|
|
c37816e90d | ||
|
|
a35cfcdca7 | ||
|
|
d381646226 | ||
|
|
285e7afec2 | ||
|
|
df7d678c32 | ||
|
|
f36f7e58de | ||
|
|
0e16c834d8 | ||
|
|
31a3256128 | ||
|
|
aa8f70da28 | ||
|
|
de240e40a5 | ||
|
|
3bbdc828d9 | ||
|
|
0d41a1ae70 | ||
|
|
b2b92aea31 | ||
|
|
e0680d9612 | ||
|
|
d286df649e | ||
|
|
e0cbc247b2 | ||
|
|
a2c8a82559 | ||
|
|
8b91323705 | ||
|
|
89fdbf5660 | ||
|
|
7ed5dfdb8f | ||
|
|
824c65eb38 | ||
|
|
e43eeeba4a | ||
|
|
9e2c7cc145 | ||
|
|
989598b9cd | ||
|
|
6a41942de6 | ||
|
|
d263072aca | ||
|
|
78c8467bf6 | ||
|
|
084908bd04 | ||
|
|
039a775ce4 | ||
|
|
dd9e80f067 | ||
|
|
a3a2af948e | ||
|
|
0bedf3ea59 | ||
|
|
1775ac4803 | ||
|
|
ae1a615863 | ||
|
|
a6ef1b8902 | ||
|
|
94d17b81d4 | ||
|
|
44a63d9cec | ||
|
|
e2b4b5a57e | ||
|
|
ec30aa323e | ||
|
|
95e9087c6e | ||
|
|
db38099557 | ||
|
|
164d5cdec9 | ||
|
|
c6b1076092 | ||
|
|
6aebe856da | ||
|
|
4737551918 | ||
|
|
c2cb79f700 | ||
|
|
d7c05984be | ||
|
|
55429e2f03 | ||
|
|
25ffe8b0e4 | ||
|
|
303a9d1110 | ||
|
|
de8c907c51 | ||
|
|
0fd1cabd60 | ||
|
|
8dd480735c | ||
|
|
676f1a1f0e | ||
|
|
ce75422126 | ||
|
|
3a097d6b15 | ||
|
|
9de1bf1020 | ||
|
|
93e5cf1c25 | ||
|
|
6e2376570b | ||
|
|
b20c4bf197 | ||
|
|
6ae1d92033 |
2
.github/helper/install_dependencies.sh
vendored
2
.github/helper/install_dependencies.sh
vendored
@@ -5,7 +5,7 @@ echo "Setting Up System Dependencies..."
|
|||||||
|
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt remove mysql-server mysql-client
|
sudo apt remove mysql-server mysql-client
|
||||||
sudo apt-get install libcups2-dev redis-server mariadb-client
|
sudo apt-get install libcups2-dev redis-server mariadb-client libmariadb-dev
|
||||||
|
|
||||||
install_wkhtmltopdf() {
|
install_wkhtmltopdf() {
|
||||||
wget -q https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.focal_amd64.deb
|
wget -q https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.focal_amd64.deb
|
||||||
|
|||||||
2
.github/workflows/make_release_pr.yml
vendored
2
.github/workflows/make_release_pr.yml
vendored
@@ -1,7 +1,7 @@
|
|||||||
name: Create weekly release
|
name: Create weekly release
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '30 4 15 * *'
|
- cron: '30 3 * * 3'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -118,6 +118,10 @@ Replace the following parameters with your values:
|
|||||||
|
|
||||||
The script will set up a production-ready instance of Frappe Learning with all the necessary configurations in about 5 minutes.
|
The script will set up a production-ready instance of Frappe Learning with all the necessary configurations in about 5 minutes.
|
||||||
|
|
||||||
|
**Note:** To avoid a `404 Page Not Found` error:
|
||||||
|
- If hosting on a **public server**, make sure your DNS **A record** points to your server's IP.
|
||||||
|
- If hosting **locally**, map your domain to `127.0.0.1` in your `/etc/hosts` file:
|
||||||
|
|
||||||
## Development Setup
|
## Development Setup
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|||||||
180
cypress/e2e/batch_creation.cy.js
Normal file
180
cypress/e2e/batch_creation.cy.js
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
describe("Batch Creation", () => {
|
||||||
|
it("creates a new batch", () => {
|
||||||
|
cy.login();
|
||||||
|
cy.wait(500);
|
||||||
|
cy.visit("/lms/batches");
|
||||||
|
cy.closeOnboardingModal();
|
||||||
|
|
||||||
|
// Open Settings
|
||||||
|
cy.get("span").contains("Learning").click();
|
||||||
|
cy.get("span").contains("Settings").click();
|
||||||
|
|
||||||
|
// Add a new member
|
||||||
|
cy.get('[id^="headlessui-dialog-panel-v-"]')
|
||||||
|
.find("span")
|
||||||
|
.contains(/^Members$/)
|
||||||
|
.click();
|
||||||
|
cy.get('[id^="headlessui-dialog-panel-v-"]')
|
||||||
|
.find("button")
|
||||||
|
.contains("New")
|
||||||
|
.click();
|
||||||
|
|
||||||
|
const dateNow = Date.now();
|
||||||
|
const randomEmail = `testuser_${dateNow}@example.com`;
|
||||||
|
const randomName = `Test User ${dateNow}`;
|
||||||
|
|
||||||
|
cy.get("input[placeholder='jane@doe.com']").type(randomEmail);
|
||||||
|
cy.get("input[placeholder='Jane']").type(randomName);
|
||||||
|
cy.get("button").contains("Add").click();
|
||||||
|
|
||||||
|
// Add evaluator
|
||||||
|
cy.get('[id^="headlessui-dialog-panel-v-"]')
|
||||||
|
.find("span")
|
||||||
|
.contains(/^Evaluators$/)
|
||||||
|
.click();
|
||||||
|
|
||||||
|
cy.get('[id^="headlessui-dialog-panel-v-"]')
|
||||||
|
.find("button")
|
||||||
|
.contains("New")
|
||||||
|
.click();
|
||||||
|
const randomEvaluator = `evaluator${dateNow}@example.com`;
|
||||||
|
|
||||||
|
cy.get("input[placeholder='jane@doe.com']").type(randomEvaluator);
|
||||||
|
cy.get("button").contains("Add").click();
|
||||||
|
cy.get("div").contains(randomEvaluator).should("be.visible").click();
|
||||||
|
|
||||||
|
cy.visit("/lms/batches");
|
||||||
|
cy.closeOnboardingModal();
|
||||||
|
|
||||||
|
// Create a batch
|
||||||
|
cy.get("button").contains("Create").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")
|
||||||
|
.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")
|
||||||
|
.parent()
|
||||||
|
.within(() => {
|
||||||
|
cy.get("input").click().type("evaluator");
|
||||||
|
cy.get("input")
|
||||||
|
.invoke("attr", "aria-controls")
|
||||||
|
.as("instructor_list_id");
|
||||||
|
});
|
||||||
|
cy.get("@instructor_list_id").then((instructor_list_id) => {
|
||||||
|
cy.get(`[id^=${instructor_list_id}`)
|
||||||
|
.should("be.visible")
|
||||||
|
.within(() => {
|
||||||
|
cy.get("[id^=headlessui-combobox-option-").first().click();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.button("Save").click();
|
||||||
|
cy.wait(1000);
|
||||||
|
let batchName;
|
||||||
|
cy.url().then((url) => {
|
||||||
|
console.log(url);
|
||||||
|
batchName = url.split("/").pop();
|
||||||
|
cy.wrap(batchName).as("batchName");
|
||||||
|
});
|
||||||
|
cy.wait(500);
|
||||||
|
|
||||||
|
// View Batch
|
||||||
|
cy.wait(1000);
|
||||||
|
cy.visit("/lms/batches");
|
||||||
|
cy.closeOnboardingModal();
|
||||||
|
|
||||||
|
cy.url().should("include", "/lms/batches");
|
||||||
|
|
||||||
|
cy.get('[id^="headlessui-radiogroup-v-"]')
|
||||||
|
.find("span")
|
||||||
|
.contains("Upcoming")
|
||||||
|
.should("be.visible")
|
||||||
|
.click();
|
||||||
|
|
||||||
|
cy.get("@batchName").then((batchName) => {
|
||||||
|
cy.get(`a[href='/lms/batches/details/${batchName}'`).within(() => {
|
||||||
|
cy.get("div").contains("Test Batch").should("be.visible");
|
||||||
|
cy.get("div")
|
||||||
|
.contains("Test Batch Short Description to test the UI")
|
||||||
|
.should("be.visible");
|
||||||
|
cy.get("span")
|
||||||
|
.contains("01 Oct 2030 - 31 Oct 2030")
|
||||||
|
.should("be.visible");
|
||||||
|
cy.get("span")
|
||||||
|
.contains("10:00 AM - 11:00 AM")
|
||||||
|
.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.get(`a[href='/lms/batches/details/${batchName}'`).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get("div").contains("Test Batch").should("be.visible");
|
||||||
|
cy.get("div")
|
||||||
|
.contains("Test Batch Short Description to test the UI")
|
||||||
|
.should("be.visible");
|
||||||
|
cy.get("a").contains("Evaluator").should("be.visible");
|
||||||
|
cy.get("span")
|
||||||
|
.contains("01 Oct 2030 - 31 Oct 2030")
|
||||||
|
.should("be.visible");
|
||||||
|
cy.get("span").contains("10:00 AM - 11:00 AM").should("be.visible");
|
||||||
|
cy.get("span").contains("IST").should("be.visible");
|
||||||
|
cy.get("div")
|
||||||
|
.contains("10")
|
||||||
|
.should("be.visible")
|
||||||
|
.get("span")
|
||||||
|
.contains("Seats Left")
|
||||||
|
.should("be.visible");
|
||||||
|
|
||||||
|
cy.get("p")
|
||||||
|
.contains(
|
||||||
|
"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").contains("Manage Batch").click();
|
||||||
|
|
||||||
|
/* Add student to batch */
|
||||||
|
cy.get("button").contains("Add").click();
|
||||||
|
cy.get('div[id^="headlessui-dialog-panel-v-"]')
|
||||||
|
.first()
|
||||||
|
.find("button")
|
||||||
|
.eq(1)
|
||||||
|
.click();
|
||||||
|
cy.get("input[id^='headlessui-combobox-input-v-']").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("div")
|
||||||
|
.contains("9")
|
||||||
|
.should("be.visible")
|
||||||
|
.get("span")
|
||||||
|
.contains("Seats Left")
|
||||||
|
.should("be.visible");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
describe("Course Creation", () => {
|
describe("Course Creation", () => {
|
||||||
it("creates a new course", () => {
|
it("creates a new course", () => {
|
||||||
cy.login();
|
cy.login();
|
||||||
cy.wait(1000);
|
cy.wait(500);
|
||||||
cy.visit("/lms/courses");
|
cy.visit("/lms/courses");
|
||||||
|
|
||||||
|
// Close onboarding modal
|
||||||
|
cy.closeOnboardingModal();
|
||||||
|
|
||||||
// Create a course
|
// Create a course
|
||||||
cy.get("button").contains("New").click();
|
cy.get("button").contains("Create").click();
|
||||||
cy.wait(1000);
|
cy.wait(500);
|
||||||
cy.url().should("include", "/courses/new/edit");
|
cy.url().should("include", "/courses/new/edit");
|
||||||
|
|
||||||
cy.get("label").contains("Title").type("Test Course");
|
cy.get("label").contains("Title").type("Test Course");
|
||||||
@@ -96,14 +99,15 @@ describe("Course Creation", () => {
|
|||||||
// View Course
|
// View Course
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.visit("/lms");
|
cy.visit("/lms");
|
||||||
cy.wait(500);
|
cy.closeOnboardingModal();
|
||||||
|
|
||||||
cy.url().should("include", "/lms/courses");
|
cy.url().should("include", "/lms/courses");
|
||||||
cy.get(".grid a:first").within(() => {
|
cy.get(".grid a:first").within(() => {
|
||||||
cy.get("div").contains("Test Course");
|
cy.get("div").contains("Test Course");
|
||||||
cy.get("div").contains(
|
cy.get("div").contains(
|
||||||
"Test Course Short Introduction to test the UI"
|
"Test Course Short Introduction to test the UI"
|
||||||
);
|
);
|
||||||
cy.get(".course-image")
|
cy.get(".bg-cover")
|
||||||
.invoke("css", "background-image")
|
.invoke("css", "background-image")
|
||||||
.should("include", "/files/profile");
|
.should("include", "/files/profile");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||||
|
|
||||||
import "cypress-file-upload";
|
import "cypress-file-upload";
|
||||||
|
import "cypress-real-events";
|
||||||
|
|
||||||
Cypress.Commands.add("login", (email, password) => {
|
Cypress.Commands.add("login", (email, password) => {
|
||||||
if (!email) {
|
if (!email) {
|
||||||
@@ -68,3 +69,18 @@ Cypress.Commands.add("paste", { prevSubject: true }, (subject, text) => {
|
|||||||
element.dispatchEvent(event);
|
element.dispatchEvent(event);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add("closeOnboardingModal", () => {
|
||||||
|
cy.wait(500);
|
||||||
|
cy.get("body").then(($body) => {
|
||||||
|
// Check if any element with class including 'z-50' exists
|
||||||
|
if ($body.find('[class*="z-50"]').length > 0) {
|
||||||
|
cy.get('[class*="z-50"]')
|
||||||
|
.find('button:has(svg[class*="feather-x"])')
|
||||||
|
.realClick();
|
||||||
|
cy.wait(1000);
|
||||||
|
} else {
|
||||||
|
cy.log("Onboarding modal not found, skipping close.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
1
frappe-ui
Submodule
1
frappe-ui
Submodule
Submodule frappe-ui added at 80d3a010ac
34
frontend/components.d.ts
vendored
34
frontend/components.d.ts
vendored
@@ -19,6 +19,10 @@ declare module 'vue' {
|
|||||||
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
|
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
|
||||||
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
|
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
|
||||||
Autocomplete: typeof import('./src/components/Controls/Autocomplete.vue')['default']
|
Autocomplete: typeof import('./src/components/Controls/Autocomplete.vue')['default']
|
||||||
|
BadgeAssignmentForm: typeof import('./src/components/Settings/BadgeAssignmentForm.vue')['default']
|
||||||
|
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']
|
BatchCard: typeof import('./src/components/BatchCard.vue')['default']
|
||||||
BatchCourseModal: typeof import('./src/components/Modals/BatchCourseModal.vue')['default']
|
BatchCourseModal: typeof import('./src/components/Modals/BatchCourseModal.vue')['default']
|
||||||
BatchCourses: typeof import('./src/components/BatchCourses.vue')['default']
|
BatchCourses: typeof import('./src/components/BatchCourses.vue')['default']
|
||||||
@@ -27,17 +31,21 @@ declare module 'vue' {
|
|||||||
BatchOverlay: typeof import('./src/components/BatchOverlay.vue')['default']
|
BatchOverlay: typeof import('./src/components/BatchOverlay.vue')['default']
|
||||||
BatchStudentProgress: typeof import('./src/components/Modals/BatchStudentProgress.vue')['default']
|
BatchStudentProgress: typeof import('./src/components/Modals/BatchStudentProgress.vue')['default']
|
||||||
BatchStudents: typeof import('./src/components/BatchStudents.vue')['default']
|
BatchStudents: typeof import('./src/components/BatchStudents.vue')['default']
|
||||||
BrandSettings: typeof import('./src/components/BrandSettings.vue')['default']
|
BrandSettings: typeof import('./src/components/Settings/BrandSettings.vue')['default']
|
||||||
BulkCertificates: typeof import('./src/components/Modals/BulkCertificates.vue')['default']
|
BulkCertificates: typeof import('./src/components/Modals/BulkCertificates.vue')['default']
|
||||||
Categories: typeof import('./src/components/Categories.vue')['default']
|
Categories: typeof import('./src/components/Settings/Categories.vue')['default']
|
||||||
CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default']
|
CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default']
|
||||||
ChapterModal: typeof import('./src/components/Modals/ChapterModal.vue')['default']
|
ChapterModal: typeof import('./src/components/Modals/ChapterModal.vue')['default']
|
||||||
|
ChildTable: typeof import('./src/components/Controls/ChildTable.vue')['default']
|
||||||
|
Code: typeof import('./src/components/Controls/Code.vue')['default']
|
||||||
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
|
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
|
||||||
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
|
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
|
||||||
|
ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default']
|
||||||
CourseCard: typeof import('./src/components/CourseCard.vue')['default']
|
CourseCard: typeof import('./src/components/CourseCard.vue')['default']
|
||||||
CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default']
|
CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default']
|
||||||
CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default']
|
CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default']
|
||||||
CourseOutline: typeof import('./src/components/CourseOutline.vue')['default']
|
CourseOutline: typeof import('./src/components/CourseOutline.vue')['default']
|
||||||
|
CourseProgressSummary: typeof import('./src/components/Modals/CourseProgressSummary.vue')['default']
|
||||||
CourseReviews: typeof import('./src/components/CourseReviews.vue')['default']
|
CourseReviews: typeof import('./src/components/CourseReviews.vue')['default']
|
||||||
CreateOutline: typeof import('./src/components/CreateOutline.vue')['default']
|
CreateOutline: typeof import('./src/components/CreateOutline.vue')['default']
|
||||||
DateRange: typeof import('./src/components/Common/DateRange.vue')['default']
|
DateRange: typeof import('./src/components/Common/DateRange.vue')['default']
|
||||||
@@ -47,11 +55,14 @@ declare module 'vue' {
|
|||||||
Discussions: typeof import('./src/components/Discussions.vue')['default']
|
Discussions: typeof import('./src/components/Discussions.vue')['default']
|
||||||
EditCoverImage: typeof import('./src/components/Modals/EditCoverImage.vue')['default']
|
EditCoverImage: typeof import('./src/components/Modals/EditCoverImage.vue')['default']
|
||||||
EditProfile: typeof import('./src/components/Modals/EditProfile.vue')['default']
|
EditProfile: typeof import('./src/components/Modals/EditProfile.vue')['default']
|
||||||
|
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
|
||||||
|
EmailTemplates: typeof import('./src/components/Settings/EmailTemplates.vue')['default']
|
||||||
EmptyState: typeof import('./src/components/EmptyState.vue')['default']
|
EmptyState: typeof import('./src/components/EmptyState.vue')['default']
|
||||||
EvaluationModal: typeof import('./src/components/Modals/EvaluationModal.vue')['default']
|
EvaluationModal: typeof import('./src/components/Modals/EvaluationModal.vue')['default']
|
||||||
Evaluators: typeof import('./src/components/Evaluators.vue')['default']
|
Evaluators: typeof import('./src/components/Settings/Evaluators.vue')['default']
|
||||||
Event: typeof import('./src/components/Modals/Event.vue')['default']
|
Event: typeof import('./src/components/Modals/Event.vue')['default']
|
||||||
ExplanationVideos: typeof import('./src/components/Modals/ExplanationVideos.vue')['default']
|
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']
|
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
|
||||||
IconPicker: typeof import('./src/components/Controls/IconPicker.vue')['default']
|
IconPicker: typeof import('./src/components/Controls/IconPicker.vue')['default']
|
||||||
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
|
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
|
||||||
@@ -62,37 +73,44 @@ declare module 'vue' {
|
|||||||
LessonHelp: typeof import('./src/components/LessonHelp.vue')['default']
|
LessonHelp: typeof import('./src/components/LessonHelp.vue')['default']
|
||||||
Link: typeof import('./src/components/Controls/Link.vue')['default']
|
Link: typeof import('./src/components/Controls/Link.vue')['default']
|
||||||
LiveClass: typeof import('./src/components/LiveClass.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']
|
LiveClassModal: typeof import('./src/components/Modals/LiveClassModal.vue')['default']
|
||||||
LMSLogo: typeof import('./src/components/Icons/LMSLogo.vue')['default']
|
LMSLogo: typeof import('./src/components/Icons/LMSLogo.vue')['default']
|
||||||
Members: typeof import('./src/components/Members.vue')['default']
|
Members: typeof import('./src/components/Settings/Members.vue')['default']
|
||||||
MobileLayout: typeof import('./src/components/MobileLayout.vue')['default']
|
MobileLayout: typeof import('./src/components/MobileLayout.vue')['default']
|
||||||
MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default']
|
MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default']
|
||||||
NoPermission: typeof import('./src/components/NoPermission.vue')['default']
|
NoPermission: typeof import('./src/components/NoPermission.vue')['default']
|
||||||
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
|
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
|
||||||
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
|
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
|
||||||
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
|
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
|
||||||
PaymentSettings: typeof import('./src/components/PaymentSettings.vue')['default']
|
PaymentSettings: typeof import('./src/components/Settings/PaymentSettings.vue')['default']
|
||||||
Play: typeof import('./src/components/Icons/Play.vue')['default']
|
Play: typeof import('./src/components/Icons/Play.vue')['default']
|
||||||
ProgressBar: typeof import('./src/components/ProgressBar.vue')['default']
|
ProgressBar: typeof import('./src/components/ProgressBar.vue')['default']
|
||||||
Question: typeof import('./src/components/Modals/Question.vue')['default']
|
Question: typeof import('./src/components/Modals/Question.vue')['default']
|
||||||
Quiz: typeof import('./src/components/Quiz.vue')['default']
|
Quiz: typeof import('./src/components/Quiz.vue')['default']
|
||||||
QuizBlock: typeof import('./src/components/QuizBlock.vue')['default']
|
QuizBlock: typeof import('./src/components/QuizBlock.vue')['default']
|
||||||
|
QuizInVideo: typeof import('./src/components/Modals/QuizInVideo.vue')['default']
|
||||||
Rating: typeof import('./src/components/Controls/Rating.vue')['default']
|
Rating: typeof import('./src/components/Controls/Rating.vue')['default']
|
||||||
|
RelatedCourses: typeof import('./src/components/RelatedCourses.vue')['default']
|
||||||
ReviewModal: typeof import('./src/components/Modals/ReviewModal.vue')['default']
|
ReviewModal: typeof import('./src/components/Modals/ReviewModal.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
SettingDetails: typeof import('./src/components/SettingDetails.vue')['default']
|
SettingDetails: typeof import('./src/components/Settings/SettingDetails.vue')['default']
|
||||||
SettingFields: typeof import('./src/components/SettingFields.vue')['default']
|
SettingFields: typeof import('./src/components/Settings/SettingFields.vue')['default']
|
||||||
Settings: typeof import('./src/components/Modals/Settings.vue')['default']
|
Settings: typeof import('./src/components/Settings/Settings.vue')['default']
|
||||||
SidebarLink: typeof import('./src/components/SidebarLink.vue')['default']
|
SidebarLink: typeof import('./src/components/SidebarLink.vue')['default']
|
||||||
StudentHeatmap: typeof import('./src/components/StudentHeatmap.vue')['default']
|
StudentHeatmap: typeof import('./src/components/StudentHeatmap.vue')['default']
|
||||||
StudentModal: typeof import('./src/components/Modals/StudentModal.vue')['default']
|
StudentModal: typeof import('./src/components/Modals/StudentModal.vue')['default']
|
||||||
Tags: typeof import('./src/components/Tags.vue')['default']
|
Tags: typeof import('./src/components/Tags.vue')['default']
|
||||||
UnsplashImageBrowser: typeof import('./src/components/UnsplashImageBrowser.vue')['default']
|
UnsplashImageBrowser: typeof import('./src/components/UnsplashImageBrowser.vue')['default']
|
||||||
UpcomingEvaluations: typeof import('./src/components/UpcomingEvaluations.vue')['default']
|
UpcomingEvaluations: typeof import('./src/components/UpcomingEvaluations.vue')['default']
|
||||||
|
Uploader: typeof import('./src/components/Controls/Uploader.vue')['default']
|
||||||
UploadPlugin: typeof import('./src/components/UploadPlugin.vue')['default']
|
UploadPlugin: typeof import('./src/components/UploadPlugin.vue')['default']
|
||||||
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
|
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
|
||||||
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
|
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
|
||||||
VideoBlock: typeof import('./src/components/VideoBlock.vue')['default']
|
VideoBlock: typeof import('./src/components/VideoBlock.vue')['default']
|
||||||
|
VideoStatistics: typeof import('./src/components/Modals/VideoStatistics.vue')['default']
|
||||||
|
ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default']
|
||||||
|
ZoomSettings: typeof import('./src/components/Settings/ZoomSettings.vue')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html"
|
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/lang-html": "^6.4.9",
|
||||||
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
|
"@codemirror/lang-json": "^6.0.1",
|
||||||
|
"@codemirror/lang-python": "^6.2.1",
|
||||||
"@editorjs/checklist": "^1.6.0",
|
"@editorjs/checklist": "^1.6.0",
|
||||||
"@editorjs/code": "^2.9.0",
|
"@editorjs/code": "^2.9.0",
|
||||||
"@editorjs/editorjs": "^2.29.0",
|
"@editorjs/editorjs": "^2.29.0",
|
||||||
@@ -24,10 +28,10 @@
|
|||||||
"ace-builds": "^1.36.2",
|
"ace-builds": "^1.36.2",
|
||||||
"apexcharts": "^4.3.0",
|
"apexcharts": "^4.3.0",
|
||||||
"chart.js": "^4.4.1",
|
"chart.js": "^4.4.1",
|
||||||
"codemirror-editor-vue3": "^2.8.0",
|
"codemirror": "^6.0.1",
|
||||||
"dayjs": "^1.11.6",
|
"dayjs": "^1.11.6",
|
||||||
"feather-icons": "^4.28.0",
|
"feather-icons": "^4.28.0",
|
||||||
"frappe-ui": "^0.1.143",
|
"frappe-ui": "^0.1.172",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"lucide-vue-next": "^0.383.0",
|
"lucide-vue-next": "^0.383.0",
|
||||||
"markdown-it": "^14.0.0",
|
"markdown-it": "^14.0.0",
|
||||||
@@ -35,9 +39,11 @@
|
|||||||
"plyr": "^3.7.8",
|
"plyr": "^3.7.8",
|
||||||
"socket.io-client": "^4.7.2",
|
"socket.io-client": "^4.7.2",
|
||||||
"tailwindcss": "3.4.15",
|
"tailwindcss": "3.4.15",
|
||||||
|
"thememirror": "^2.0.1",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vue": "^3.4.23",
|
"vue": "^3.4.23",
|
||||||
"vue-chartjs": "^5.3.0",
|
"vue-chartjs": "^5.3.0",
|
||||||
|
"vue-codemirror": "^6.1.1",
|
||||||
"vue-draggable-next": "^2.2.1",
|
"vue-draggable-next": "^2.2.1",
|
||||||
"vue-router": "^4.0.12",
|
"vue-router": "^4.0.12",
|
||||||
"vue3-apexcharts": "^1.8.0",
|
"vue3-apexcharts": "^1.8.0",
|
||||||
|
|||||||
BIN
frontend/public/Remove.mp4
Normal file
BIN
frontend/public/Remove.mp4
Normal file
Binary file not shown.
@@ -1,7 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<FrappeUIProvider>
|
<FrappeUIProvider>
|
||||||
<Layout>
|
<Layout>
|
||||||
|
<div class="text-base">
|
||||||
<router-view />
|
<router-view />
|
||||||
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
<Dialogs />
|
<Dialogs />
|
||||||
</FrappeUIProvider>
|
</FrappeUIProvider>
|
||||||
@@ -9,20 +11,19 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { FrappeUIProvider } from 'frappe-ui'
|
import { FrappeUIProvider } from 'frappe-ui'
|
||||||
import { Dialogs } from '@/utils/dialogs'
|
import { Dialogs } from '@/utils/dialogs'
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useScreenSize } from './utils/composables'
|
import { useScreenSize } from './utils/composables'
|
||||||
import DesktopLayout from './components/DesktopLayout.vue'
|
import DesktopLayout from './components/DesktopLayout.vue'
|
||||||
import MobileLayout from './components/MobileLayout.vue'
|
import MobileLayout from './components/MobileLayout.vue'
|
||||||
import NoSidebarLayout from './components/NoSidebarLayout.vue'
|
import NoSidebarLayout from './components/NoSidebarLayout.vue'
|
||||||
import { stopSession } from '@/telemetry'
|
|
||||||
import { init as initTelemetry } from '@/telemetry'
|
|
||||||
import { usersStore } from '@/stores/user'
|
import { usersStore } from '@/stores/user'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { posthogSettings } from '@/telemetry'
|
||||||
|
|
||||||
const screenSize = useScreenSize()
|
const screenSize = useScreenSize()
|
||||||
let { userResource } = usersStore()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const noSidebar = ref(false)
|
const noSidebar = ref(false)
|
||||||
|
const { userResource } = usersStore()
|
||||||
|
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to, from, next) => {
|
||||||
if (to.query.fromLesson || to.path === '/persona') {
|
if (to.query.fromLesson || to.path === '/persona') {
|
||||||
@@ -39,17 +40,18 @@ const Layout = computed(() => {
|
|||||||
}
|
}
|
||||||
if (screenSize.width < 640) {
|
if (screenSize.width < 640) {
|
||||||
return MobileLayout
|
return MobileLayout
|
||||||
} else {
|
|
||||||
return DesktopLayout
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
return DesktopLayout
|
||||||
if (userResource.data) await initTelemetry()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
noSidebar.value = false
|
noSidebar.value = false
|
||||||
stopSession()
|
})
|
||||||
|
|
||||||
|
watch(userResource, () => {
|
||||||
|
if (userResource.data) {
|
||||||
|
posthogSettings.reload()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -181,9 +181,17 @@
|
|||||||
import UserDropdown from '@/components/UserDropdown.vue'
|
import UserDropdown from '@/components/UserDropdown.vue'
|
||||||
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
||||||
import SidebarLink from '@/components/SidebarLink.vue'
|
import SidebarLink from '@/components/SidebarLink.vue'
|
||||||
import { useStorage } from '@vueuse/core'
|
import {
|
||||||
import { ref, onMounted, inject, watch, reactive, markRaw, h } from 'vue'
|
ref,
|
||||||
import { getSidebarLinks } from '../utils'
|
onMounted,
|
||||||
|
inject,
|
||||||
|
watch,
|
||||||
|
reactive,
|
||||||
|
markRaw,
|
||||||
|
h,
|
||||||
|
onUnmounted,
|
||||||
|
} from 'vue'
|
||||||
|
import { getSidebarLinks } from '@/utils'
|
||||||
import { usersStore } from '@/stores/user'
|
import { usersStore } from '@/stores/user'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { useSidebar } from '@/stores/sidebar'
|
import { useSidebar } from '@/stores/sidebar'
|
||||||
@@ -217,7 +225,7 @@ import {
|
|||||||
IntermediateStepModal,
|
IntermediateStepModal,
|
||||||
} from 'frappe-ui/frappe'
|
} from 'frappe-ui/frappe'
|
||||||
|
|
||||||
const { user, sidebarSettings } = sessionStore()
|
const { user } = sessionStore()
|
||||||
const { userResource } = usersStore()
|
const { userResource } = usersStore()
|
||||||
let sidebarStore = useSidebar()
|
let sidebarStore = useSidebar()
|
||||||
const socket = inject('$socket')
|
const socket = inject('$socket')
|
||||||
@@ -228,6 +236,7 @@ const isModerator = ref(false)
|
|||||||
const isInstructor = ref(false)
|
const isInstructor = ref(false)
|
||||||
const pageToEdit = ref(null)
|
const pageToEdit = ref(null)
|
||||||
const settingsStore = useSettings()
|
const settingsStore = useSettings()
|
||||||
|
const { sidebarSettings } = settingsStore
|
||||||
const showOnboarding = ref(false)
|
const showOnboarding = ref(false)
|
||||||
const showIntermediateModal = ref(false)
|
const showIntermediateModal = ref(false)
|
||||||
const currentStep = ref({})
|
const currentStep = ref({})
|
||||||
@@ -244,6 +253,7 @@ const iconProps = {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
addNotifications()
|
addNotifications()
|
||||||
setSidebarLinks()
|
setSidebarLinks()
|
||||||
|
setUpOnboarding()
|
||||||
socket.on('publish_lms_notifications', (data) => {
|
socket.on('publish_lms_notifications', (data) => {
|
||||||
unreadNotifications.reload()
|
unreadNotifications.reload()
|
||||||
})
|
})
|
||||||
@@ -304,7 +314,7 @@ const addNotifications = () => {
|
|||||||
|
|
||||||
const addQuizzes = () => {
|
const addQuizzes = () => {
|
||||||
if (isInstructor.value || isModerator.value) {
|
if (isInstructor.value || isModerator.value) {
|
||||||
sidebarLinks.value.push({
|
sidebarLinks.value.splice(4, 0, {
|
||||||
label: 'Quizzes',
|
label: 'Quizzes',
|
||||||
icon: 'CircleHelp',
|
icon: 'CircleHelp',
|
||||||
to: 'Quizzes',
|
to: 'Quizzes',
|
||||||
@@ -320,7 +330,7 @@ const addQuizzes = () => {
|
|||||||
|
|
||||||
const addAssignments = () => {
|
const addAssignments = () => {
|
||||||
if (isInstructor.value || isModerator.value) {
|
if (isInstructor.value || isModerator.value) {
|
||||||
sidebarLinks.value.push({
|
sidebarLinks.value.splice(5, 0, {
|
||||||
label: 'Assignments',
|
label: 'Assignments',
|
||||||
icon: 'Pencil',
|
icon: 'Pencil',
|
||||||
to: 'Assignments',
|
to: 'Assignments',
|
||||||
@@ -334,6 +344,22 @@ const addAssignments = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addProgrammingExercises = () => {
|
||||||
|
if (isInstructor.value || isModerator.value) {
|
||||||
|
sidebarLinks.value.splice(3, 0, {
|
||||||
|
label: 'Programming Exercises',
|
||||||
|
icon: 'Code',
|
||||||
|
to: 'ProgrammingExercises',
|
||||||
|
activeFor: [
|
||||||
|
'ProgrammingExercises',
|
||||||
|
'ProgrammingExerciseForm',
|
||||||
|
'ProgrammingExerciseSubmissions',
|
||||||
|
'ProgrammingExerciseSubmission',
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const addPrograms = () => {
|
const addPrograms = () => {
|
||||||
let activeFor = ['Programs', 'ProgramForm']
|
let activeFor = ['Programs', 'ProgramForm']
|
||||||
let index = 1
|
let index = 1
|
||||||
@@ -388,10 +414,6 @@ const deletePage = (link) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSidebarFromStorage = () => {
|
|
||||||
return useStorage('sidebar_is_collapsed', false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
const toggleSidebar = () => {
|
||||||
sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed
|
sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
@@ -438,6 +460,7 @@ const steps = reactive([
|
|||||||
title: __('Add your first chapter'),
|
title: __('Add your first chapter'),
|
||||||
icon: markRaw(h(FolderTree, iconProps)),
|
icon: markRaw(h(FolderTree, iconProps)),
|
||||||
completed: false,
|
completed: false,
|
||||||
|
dependsOn: 'create_first_course',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
minimize.value = true
|
minimize.value = true
|
||||||
let course = await getFirstCourse()
|
let course = await getFirstCourse()
|
||||||
@@ -453,6 +476,7 @@ const steps = reactive([
|
|||||||
title: __('Add your first lesson'),
|
title: __('Add your first lesson'),
|
||||||
icon: markRaw(h(FileText, iconProps)),
|
icon: markRaw(h(FileText, iconProps)),
|
||||||
completed: false,
|
completed: false,
|
||||||
|
dependsOn: 'create_first_chapter',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
minimize.value = true
|
minimize.value = true
|
||||||
let course = await getFirstCourse()
|
let course = await getFirstCourse()
|
||||||
@@ -471,6 +495,7 @@ const steps = reactive([
|
|||||||
title: __('Create your first quiz'),
|
title: __('Create your first quiz'),
|
||||||
icon: markRaw(h(CircleHelp, iconProps)),
|
icon: markRaw(h(CircleHelp, iconProps)),
|
||||||
completed: false,
|
completed: false,
|
||||||
|
dependsOn: 'create_first_course',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
minimize.value = true
|
minimize.value = true
|
||||||
router.push({ name: 'Quizzes' })
|
router.push({ name: 'Quizzes' })
|
||||||
@@ -502,6 +527,7 @@ const steps = reactive([
|
|||||||
title: __('Add students to your batch'),
|
title: __('Add students to your batch'),
|
||||||
icon: markRaw(h(UserPlus, iconProps)),
|
icon: markRaw(h(UserPlus, iconProps)),
|
||||||
completed: false,
|
completed: false,
|
||||||
|
dependsOn: 'create_first_batch',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
minimize.value = true
|
minimize.value = true
|
||||||
let batch = await getFirstBatch()
|
let batch = await getFirstBatch()
|
||||||
@@ -522,6 +548,7 @@ const steps = reactive([
|
|||||||
title: __('Add courses to your batch'),
|
title: __('Add courses to your batch'),
|
||||||
icon: markRaw(h(BookText, iconProps)),
|
icon: markRaw(h(BookText, iconProps)),
|
||||||
completed: false,
|
completed: false,
|
||||||
|
dependsOn: 'create_first_batch',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
minimize.value = true
|
minimize.value = true
|
||||||
let batch = await getFirstBatch()
|
let batch = await getFirstBatch()
|
||||||
@@ -616,6 +643,7 @@ watch(userResource, () => {
|
|||||||
isModerator.value = userResource.data.is_moderator
|
isModerator.value = userResource.data.is_moderator
|
||||||
isInstructor.value = userResource.data.is_instructor
|
isInstructor.value = userResource.data.is_instructor
|
||||||
addPrograms()
|
addPrograms()
|
||||||
|
addProgrammingExercises()
|
||||||
addQuizzes()
|
addQuizzes()
|
||||||
addAssignments()
|
addAssignments()
|
||||||
setUpOnboarding()
|
setUpOnboarding()
|
||||||
@@ -625,4 +653,8 @@ watch(userResource, () => {
|
|||||||
const redirectToWebsite = () => {
|
const redirectToWebsite = () => {
|
||||||
window.open('https://frappe.io/learning', '_blank')
|
window.open('https://frappe.io/learning', '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
socket.off('publish_lms_notifications')
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,17 +2,24 @@
|
|||||||
<Dialog
|
<Dialog
|
||||||
v-model="show"
|
v-model="show"
|
||||||
:options="{
|
:options="{
|
||||||
|
title:
|
||||||
|
type == 'quiz'
|
||||||
|
? __('Add a quiz to your lesson')
|
||||||
|
: __('Add an assignment to your lesson'),
|
||||||
size: 'xl',
|
size: 'xl',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Save'),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: () => {
|
||||||
|
addAssessment()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body-content>
|
||||||
<div class="p-5 space-y-4">
|
<div class="">
|
||||||
<div v-if="type == 'quiz'" class="text-lg font-semibold">
|
|
||||||
{{ __('Add a quiz to your lesson') }}
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-lg font-semibold">
|
|
||||||
{{ __('Add an assignment to your lesson') }}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<Link
|
||||||
v-if="type == 'quiz'"
|
v-if="type == 'quiz'"
|
||||||
@@ -29,17 +36,12 @@
|
|||||||
:onCreate="(value, close) => redirectToForm()"
|
:onCreate="(value, close) => redirectToForm()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-2">
|
|
||||||
<Button variant="solid" @click="addAssessment()">
|
|
||||||
{{ __('Save') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, Button } from 'frappe-ui'
|
import { Dialog } from 'frappe-ui'
|
||||||
import { onMounted, ref, nextTick } from 'vue'
|
import { onMounted, ref, nextTick } from 'vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
<template #default="{ column, item }">
|
<template #default="{ column, item }">
|
||||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||||
<div v-if="column.key == 'assessment_type'">
|
<div v-if="column.key == 'assessment_type'">
|
||||||
{{ row[column.key] == 'LMS Quiz' ? 'Quiz' : 'Assignment' }}
|
{{ getAssessmentTypeLabel(row[column.key]) }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="column.key == 'title'">
|
<div v-else-if="column.key == 'title'">
|
||||||
{{ row[column.key] }}
|
{{ row[column.key] }}
|
||||||
@@ -172,6 +172,24 @@ const getRowRoute = (row) => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (row.assessment_type == 'LMS Programming Exercise') {
|
||||||
|
if (row.submission) {
|
||||||
|
return {
|
||||||
|
name: 'ProgrammingExerciseSubmission',
|
||||||
|
params: {
|
||||||
|
exerciseID: row.assessment_name,
|
||||||
|
submissionID: row.submission.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
name: 'ProgrammingExerciseSubmission',
|
||||||
|
params: {
|
||||||
|
exerciseID: row.assessment_name,
|
||||||
|
submissionID: 'new',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
name: 'QuizPage',
|
name: 'QuizPage',
|
||||||
@@ -213,7 +231,7 @@ const getAssessmentColumns = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getStatusTheme = (status) => {
|
const getStatusTheme = (status) => {
|
||||||
if (status === 'Pass') {
|
if (status === 'Pass' || status === 'Passed') {
|
||||||
return 'green'
|
return 'green'
|
||||||
} else if (status === 'Not Graded') {
|
} else if (status === 'Not Graded') {
|
||||||
return 'orange'
|
return 'orange'
|
||||||
@@ -221,4 +239,14 @@ const getStatusTheme = (status) => {
|
|||||||
return 'red'
|
return 'red'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getAssessmentTypeLabel = (type) => {
|
||||||
|
if (type == 'LMS Assignment') {
|
||||||
|
return __('Assignment')
|
||||||
|
} else if (type == 'LMS Quiz') {
|
||||||
|
return __('Quiz')
|
||||||
|
} else if (type == 'LMS Programming Exercise') {
|
||||||
|
return __('Programming Exercise')
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col border hover:border-outline-gray-4 rounded-md p-4 h-full"
|
class="flex flex-col border hover:border-outline-gray-3 rounded-md p-4 h-full"
|
||||||
style="min-height: 150px"
|
style="min-height: 150px"
|
||||||
>
|
>
|
||||||
<div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9">
|
<div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9">
|
||||||
@@ -70,9 +70,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Badge } from 'frappe-ui'
|
import { formatTime } from '@/utils'
|
||||||
import { formatTime } from '../utils'
|
import { Clock, Globe } from 'lucide-vue-next'
|
||||||
import { Clock, BookOpen, Globe } from 'lucide-vue-next'
|
|
||||||
import DateRange from '@/components/Common/DateRange.vue'
|
import DateRange from '@/components/Common/DateRange.vue'
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ const courses = createResource({
|
|||||||
params: {
|
params: {
|
||||||
batch: props.batch,
|
batch: props.batch,
|
||||||
},
|
},
|
||||||
cache: ['batchCourses', props.batchName],
|
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,12 @@
|
|||||||
:courses="batch.data.courses"
|
:courses="batch.data.courses"
|
||||||
/>
|
/>
|
||||||
<Assessments :batch="batch.data.name" />
|
<Assessments :batch="batch.data.name" />
|
||||||
<StudentHeatmap />
|
<!-- <StudentHeatmap /> -->
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||||
import Assessments from '@/components/Assessments.vue'
|
import Assessments from '@/components/Assessments.vue'
|
||||||
import StudentHeatmap from '@/components/StudentHeatmap.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="user.data?.is_student">
|
<div v-if="user.data?.is_student">
|
||||||
<div
|
<div>
|
||||||
v-if="feedbackList.data?.length"
|
<div class="leading-5 mb-4">
|
||||||
class="bg-surface-blue-2 text-blue-700 p-2 rounded-md mb-5"
|
<div v-if="readOnly">
|
||||||
|
{{ __('Thank you for providing your feedback.') }}
|
||||||
|
<span
|
||||||
|
@click="showFeedbackForm = !showFeedbackForm"
|
||||||
|
class="underline cursor-pointer"
|
||||||
|
>{{ __('Click here') }}</span
|
||||||
>
|
>
|
||||||
{{ __('Thank you for providing your feedback!') }}
|
{{ __('to view your feedback.') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex justify-between items-center mb-5">
|
<div v-else>
|
||||||
<div class="text-lg font-semibold">
|
{{ __('Help us improve by providing your feedback.') }}
|
||||||
{{ __('Help Us Improve') }}
|
|
||||||
</div>
|
</div>
|
||||||
<Button @click="submitFeedback()">
|
|
||||||
{{ __('Submit') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-8">
|
<div class="space-y-4" :class="showFeedbackForm ? 'block' : 'hidden'">
|
||||||
<div class="flex items-center justify-between">
|
<div class="space-y-4">
|
||||||
<Rating
|
<Rating
|
||||||
v-for="key in ratingKeys"
|
v-for="key in ratingKeys"
|
||||||
v-model="feedback[key]"
|
v-model="feedback[key]"
|
||||||
@@ -27,18 +28,22 @@
|
|||||||
v-model="feedback.feedback"
|
v-model="feedback.feedback"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:label="__('Feedback')"
|
:label="__('Feedback')"
|
||||||
:rows="7"
|
:rows="9"
|
||||||
:readonly="readOnly"
|
:readonly="readOnly"
|
||||||
/>
|
/>
|
||||||
|
<Button v-if="!readOnly" @click="submitFeedback">
|
||||||
|
{{ __('Submit Feedback') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="feedbackList.data?.length">
|
<div v-else-if="feedbackList.data?.length">
|
||||||
<div class="text-lg font-semibold mb-5">
|
<div class="leading-5 text-sm mb-2 mt-5">
|
||||||
{{ __('Average of Feedback Received') }}
|
{{ __('Average Feedback Received') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between mb-10">
|
<div class="space-y-4">
|
||||||
<Rating
|
<Rating
|
||||||
v-for="key in ratingKeys"
|
v-for="key in ratingKeys"
|
||||||
v-model="average[key]"
|
v-model="average[key]"
|
||||||
@@ -47,81 +52,32 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-lg font-semibold mb-5">
|
<Button variant="outline" class="mt-5" @click="showAllFeedback = true">
|
||||||
{{ __('All Feedback') }}
|
{{ __('View all feedback') }}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<ListView
|
<div v-else class="text-ink-gray-7 mt-5 leading-5">
|
||||||
:columns="feedbackColumns"
|
|
||||||
:rows="feedbackList.data"
|
|
||||||
row-key="name"
|
|
||||||
:options="{
|
|
||||||
showTooltip: false,
|
|
||||||
rowHeight: 'h-16',
|
|
||||||
selectable: false,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<ListHeader
|
|
||||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
|
||||||
></ListHeader>
|
|
||||||
<ListRows>
|
|
||||||
<ListRow
|
|
||||||
:row="row"
|
|
||||||
v-for="row in feedbackList.data"
|
|
||||||
class="group cursor-pointer feedback-list"
|
|
||||||
>
|
|
||||||
<template #default="{ column, item }">
|
|
||||||
<ListRowItem
|
|
||||||
:item="row[column.key]"
|
|
||||||
:align="column.align"
|
|
||||||
class="text-sm"
|
|
||||||
>
|
|
||||||
<template #prefix>
|
|
||||||
<div v-if="column.key == 'member_name'">
|
|
||||||
<Avatar
|
|
||||||
class="flex"
|
|
||||||
:image="row['member_image']"
|
|
||||||
:label="item"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div v-if="ratingKeys.includes(column.key)">
|
|
||||||
<Rating v-model="row[column.key]" :readonly="true" />
|
|
||||||
</div>
|
|
||||||
<div v-else class="leading-5">
|
|
||||||
{{ row[column.key] }}
|
|
||||||
</div>
|
|
||||||
</ListRowItem>
|
|
||||||
</template>
|
|
||||||
</ListRow>
|
|
||||||
</ListRows>
|
|
||||||
</ListView>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-sm italic text-center text-ink-gray-7 mt-5">
|
|
||||||
{{ __('No feedback received yet.') }}
|
{{ __('No feedback received yet.') }}
|
||||||
</div>
|
</div>
|
||||||
|
<FeedbackModal
|
||||||
|
v-if="feedbackList.data?.length"
|
||||||
|
v-model="showAllFeedback"
|
||||||
|
:feedbackList="feedbackList.data"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, inject, onMounted, reactive, ref, watch } from 'vue'
|
import { inject, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { convertToTitleCase } from '@/utils'
|
import { convertToTitleCase } from '@/utils'
|
||||||
import {
|
import { Button, createListResource, FormControl, Rating } from 'frappe-ui'
|
||||||
Avatar,
|
import FeedbackModal from '@/components/Modals/FeedbackModal.vue'
|
||||||
Button,
|
|
||||||
createListResource,
|
|
||||||
FormControl,
|
|
||||||
ListView,
|
|
||||||
ListHeader,
|
|
||||||
ListRows,
|
|
||||||
ListRow,
|
|
||||||
ListRowItem,
|
|
||||||
Rating,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const ratingKeys = ['content', 'instructors', 'value']
|
const ratingKeys = ['content', 'instructors', 'value']
|
||||||
const readOnly = ref(false)
|
const readOnly = ref(false)
|
||||||
const average = reactive({})
|
const average = reactive({})
|
||||||
const feedback = reactive({})
|
const feedback = reactive({})
|
||||||
|
const showFeedbackForm = ref(true)
|
||||||
|
const showAllFeedback = ref(false)
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
@@ -167,6 +123,7 @@ watch(
|
|||||||
if (feedbackList.data.length) {
|
if (feedbackList.data.length) {
|
||||||
let data = feedbackList.data
|
let data = feedbackList.data
|
||||||
readOnly.value = true
|
readOnly.value = true
|
||||||
|
showFeedbackForm.value = false
|
||||||
|
|
||||||
ratingKeys.forEach((key) => {
|
ratingKeys.forEach((key) => {
|
||||||
average[key] = 0
|
average[key] = 0
|
||||||
@@ -201,40 +158,11 @@ const submitFeedback = () => {
|
|||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
feedbackList.reload()
|
feedbackList.reload()
|
||||||
|
showFeedbackForm.value = false
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const feedbackColumns = computed(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: 'Member',
|
|
||||||
key: 'member_name',
|
|
||||||
width: '10rem',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Feedback',
|
|
||||||
key: 'feedback',
|
|
||||||
width: '15rem',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Content',
|
|
||||||
key: 'content',
|
|
||||||
width: '9rem',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Instructors',
|
|
||||||
key: 'instructors',
|
|
||||||
width: '9rem',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Value',
|
|
||||||
key: 'value',
|
|
||||||
width: '9rem',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.feedback-list > button > div {
|
.feedback-list > button > div {
|
||||||
|
|||||||
@@ -65,6 +65,10 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Button variant="solid" class="w-full mt-4">
|
<Button variant="solid" class="w-full mt-4">
|
||||||
|
<template #prefix>
|
||||||
|
<Settings v-if="isModerator" class="size-4 stroke-1.5" />
|
||||||
|
<LogIn v-else class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
<span>
|
<span>
|
||||||
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
|
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -85,6 +89,9 @@
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
|
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
|
||||||
|
<template #prefix>
|
||||||
|
<CreditCard class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
<span>
|
<span>
|
||||||
{{ __('Register Now') }}
|
{{ __('Register Now') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -100,6 +107,9 @@
|
|||||||
"
|
"
|
||||||
@click="enrollInBatch()"
|
@click="enrollInBatch()"
|
||||||
>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<GraduationCap class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
{{ __('Enroll Now') }}
|
{{ __('Enroll Now') }}
|
||||||
</Button>
|
</Button>
|
||||||
<router-link
|
<router-link
|
||||||
@@ -112,6 +122,9 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Button class="w-full mt-2">
|
<Button class="w-full mt-2">
|
||||||
|
<template #prefix>
|
||||||
|
<Pencil class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
<span>
|
<span>
|
||||||
{{ __('Edit') }}
|
{{ __('Edit') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -122,8 +135,17 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { inject, computed } from 'vue'
|
import { inject, computed } from 'vue'
|
||||||
import { Badge, Button, createResource, toast } from 'frappe-ui'
|
import { Button, createResource, toast } from 'frappe-ui'
|
||||||
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
|
import {
|
||||||
|
BookOpen,
|
||||||
|
Clock,
|
||||||
|
CreditCard,
|
||||||
|
Globe,
|
||||||
|
GraduationCap,
|
||||||
|
LogIn,
|
||||||
|
Pencil,
|
||||||
|
Settings,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
import { formatNumberIntoCurrency, formatTime } from '@/utils'
|
import { formatNumberIntoCurrency, formatTime } from '@/utils'
|
||||||
import DateRange from '@/components/Common/DateRange.vue'
|
import DateRange from '@/components/Common/DateRange.vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|||||||
@@ -6,103 +6,59 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-4 gap-5 mb-8">
|
<div class="grid grid-cols-4 gap-5 mb-8">
|
||||||
<div
|
<NumberChart
|
||||||
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
|
class="border rounded-md"
|
||||||
>
|
:config="{ title: __('Students'), value: students.data?.length || 0 }"
|
||||||
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
|
/>
|
||||||
<User class="w-5 h-5 stroke-1.5" />
|
|
||||||
</div>
|
<NumberChart
|
||||||
<div class="flex items-center space-x-2">
|
class="border rounded-md"
|
||||||
<span class="font-semibold">
|
:config="{
|
||||||
{{ students.data?.length }}
|
title: __('Certified'),
|
||||||
</span>
|
value: certificationCount.data || 0,
|
||||||
<span class="">
|
}"
|
||||||
{{ __('Students') }}
|
/>
|
||||||
</span>
|
|
||||||
</div>
|
<NumberChart
|
||||||
</div>
|
class="border rounded-md"
|
||||||
|
:config="{
|
||||||
<div
|
title: __('Courses'),
|
||||||
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
|
value: batch.data.courses?.length || 0,
|
||||||
>
|
}"
|
||||||
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
|
/>
|
||||||
<GraduationCap class="w-5 h-5 stroke-1.5" />
|
|
||||||
</div>
|
<NumberChart
|
||||||
<div class="flex items-center space-x-2">
|
class="border rounded-md"
|
||||||
<span class="font-semibold">
|
:config="{ title: __('Assessments'), value: assessmentCount || 0 }"
|
||||||
{{ certificationCount.data }}
|
|
||||||
</span>
|
|
||||||
<span class="">
|
|
||||||
{{ __('Certified') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
|
|
||||||
>
|
|
||||||
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
|
|
||||||
<BookOpen class="w-5 h-5 stroke-1.5" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<span class="font-semibold">
|
|
||||||
{{ batch.data.courses?.length }}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{{ __('Courses') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
|
|
||||||
>
|
|
||||||
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
|
|
||||||
<ShieldCheck class="w-5 h-5 stroke-1.5" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<span class="font-semibold">
|
|
||||||
{{ assessmentCount }}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{{ __('Assessments') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="showProgressChart" class="mb-8">
|
|
||||||
<div class="text-ink-gray-7 font-medium">
|
|
||||||
{{ __('Progress') }}
|
|
||||||
</div>
|
|
||||||
<ApexChart
|
|
||||||
:options="chartOptions"
|
|
||||||
:series="chartData"
|
|
||||||
type="bar"
|
|
||||||
:height="chartData[0].data.length * 30 + 100"
|
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
class="flex items-center justify-center text-sm text-ink-gray-7 space-x-4"
|
|
||||||
>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div
|
|
||||||
class="w-3 h-3 rounded-sm"
|
|
||||||
:style="{ 'background-color': theme.colors.green[600] }"
|
|
||||||
></div>
|
|
||||||
<div>
|
|
||||||
{{ __('Courses') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div
|
|
||||||
class="w-3 h-3 rounded-sm"
|
|
||||||
:style="{ 'background-color': theme.colors.blue[600] }"
|
|
||||||
></div>
|
|
||||||
<div>
|
|
||||||
{{ __('Assessments') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AxisChart
|
||||||
|
v-if="showProgressChart"
|
||||||
|
:config="{
|
||||||
|
data: chartData,
|
||||||
|
title: __('Batch Summary'),
|
||||||
|
subtitle: __('Progress of students in courses and assessments'),
|
||||||
|
xAxis: {
|
||||||
|
key: 'task',
|
||||||
|
title: 'Tasks',
|
||||||
|
type: 'category',
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
title: __('Number of Students'),
|
||||||
|
echartOptions: {
|
||||||
|
minInterval: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
swapXY: true,
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'value',
|
||||||
|
type: 'bar',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -214,6 +170,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
|
AxisChart,
|
||||||
Button,
|
Button,
|
||||||
createResource,
|
createResource,
|
||||||
FeatherIcon,
|
FeatherIcon,
|
||||||
@@ -224,6 +181,7 @@ import {
|
|||||||
ListRows,
|
ListRows,
|
||||||
ListView,
|
ListView,
|
||||||
ListRowItem,
|
ListRowItem,
|
||||||
|
NumberChart,
|
||||||
toast,
|
toast,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import {
|
import {
|
||||||
@@ -245,7 +203,6 @@ const showStudentModal = ref(false)
|
|||||||
const showStudentProgressModal = ref(false)
|
const showStudentProgressModal = ref(false)
|
||||||
const selectedStudent = ref(null)
|
const selectedStudent = ref(null)
|
||||||
const chartData = ref(null)
|
const chartData = ref(null)
|
||||||
const chartOptions = ref(null)
|
|
||||||
const showProgressChart = ref(false)
|
const showProgressChart = ref(false)
|
||||||
const assessmentCount = ref(0)
|
const assessmentCount = ref(0)
|
||||||
const readOnlyMode = window.read_only_mode
|
const readOnlyMode = window.read_only_mode
|
||||||
@@ -333,96 +290,49 @@ const removeStudents = (selections, unselectAll) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getChartData = () => {
|
const getChartData = () => {
|
||||||
let categories = {}
|
let tasks = []
|
||||||
|
let data = []
|
||||||
|
|
||||||
if (!students.data?.length) return []
|
students.data.forEach((row) => {
|
||||||
|
tasks = countAssessments(row, tasks)
|
||||||
Object.keys(students.data[0].courses).forEach((course) => {
|
tasks = countCourses(row, tasks)
|
||||||
categories[course] = {
|
|
||||||
value: 0,
|
|
||||||
type: 'course',
|
|
||||||
label: course,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Object.keys(students.data?.[0].assessments).forEach((assessment) => {
|
tasks.forEach((task) => {
|
||||||
categories[assessment] = {
|
data.push({
|
||||||
value: 0,
|
task: task.label,
|
||||||
type: 'assessment',
|
value: task.value,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
const countAssessments = (row, tasks) => {
|
||||||
|
Object.keys(row.assessments).forEach((assessment) => {
|
||||||
|
if (row.assessments[assessment].result === 'Pass') {
|
||||||
|
tasks.filter((task) => task.label === assessment).length
|
||||||
|
? tasks.filter((task) => task.label === assessment)[0].value++
|
||||||
|
: tasks.push({
|
||||||
|
value: 1,
|
||||||
label: assessment,
|
label: assessment,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
|
||||||
students.data.forEach((student) => {
|
const countCourses = (row, tasks) => {
|
||||||
Object.keys(student.courses).forEach((course) => {
|
Object.keys(row.courses).forEach((course) => {
|
||||||
if (student.courses[course] === 100) {
|
if (row.courses[course] === 100) {
|
||||||
categories[course].value += 1
|
tasks.filter((task) => task.label === course).length
|
||||||
|
? tasks.filter((task) => task.label === course)[0].value++
|
||||||
|
: tasks.push({
|
||||||
|
value: 1,
|
||||||
|
label: course,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
return tasks
|
||||||
Object.keys(student.assessments).forEach((assessment) => {
|
|
||||||
if (student.assessments[assessment].result === 'Pass') {
|
|
||||||
categories[assessment].value += 1
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
chartOptions.value = getChartOptions(categories)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: __('Completed by Students'),
|
|
||||||
data: Object.values(categories).map((item) => item.value),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const getChartOptions = (categories) => {
|
|
||||||
const courseColor = theme.colors.green[700]
|
|
||||||
const assessmentColor = theme.colors.blue[700]
|
|
||||||
const maxY =
|
|
||||||
students.data?.length % 5
|
|
||||||
? students.data?.length + (5 - (students.data?.length % 5))
|
|
||||||
: students.data?.length
|
|
||||||
|
|
||||||
return {
|
|
||||||
chart: {
|
|
||||||
type: 'bar',
|
|
||||||
toolbar: {
|
|
||||||
show: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plotOptions: {
|
|
||||||
bar: {
|
|
||||||
distributed: true,
|
|
||||||
borderRadius: 3,
|
|
||||||
borderRadiusApplication: 'end',
|
|
||||||
horizontal: true,
|
|
||||||
barHeight: '40%',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
colors: Object.values(categories).map((item) =>
|
|
||||||
item.type === 'course' ? courseColor : assessmentColor
|
|
||||||
),
|
|
||||||
xaxis: {
|
|
||||||
categories: Object.values(categories).map((item) => item.label),
|
|
||||||
labels: {
|
|
||||||
style: {
|
|
||||||
fontSize: '10px',
|
|
||||||
},
|
|
||||||
rotate: 0,
|
|
||||||
formatter: function (value) {
|
|
||||||
return value.length > 30 ? `${value.substring(0, 30)}...` : value
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
yaxis: {
|
|
||||||
max: maxY,
|
|
||||||
min: 0,
|
|
||||||
stepSize: 10,
|
|
||||||
tickAmount: maxY / 5,
|
|
||||||
/* reversed: true */
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(students, () => {
|
watch(students, () => {
|
||||||
@@ -442,8 +352,3 @@ const certificationCount = createResource({
|
|||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<style>
|
|
||||||
.apexcharts-legend {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col min-h-0">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="text-xl font-semibold mb-5 text-ink-gray-9">
|
|
||||||
{{ label }}
|
|
||||||
</div>
|
|
||||||
<Button @click="() => showCategoryForm()">
|
|
||||||
<template #prefix>
|
|
||||||
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
|
|
||||||
<X v-else class="h-3 w-3 stroke-1.5" />
|
|
||||||
</template>
|
|
||||||
{{ showForm ? __('Close') : __('New') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="showForm"
|
|
||||||
class="flex items-center justify-between my-4 space-x-2"
|
|
||||||
>
|
|
||||||
<FormControl
|
|
||||||
ref="categoryInput"
|
|
||||||
v-model="category"
|
|
||||||
:placeholder="__('Category Name')"
|
|
||||||
class="flex-1"
|
|
||||||
/>
|
|
||||||
<Button @click="addCategory()" variant="subtle">
|
|
||||||
{{ __('Add') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="overflow-y-scroll">
|
|
||||||
<div class="text-base space-y-2">
|
|
||||||
<FormControl
|
|
||||||
:value="cat.category"
|
|
||||||
type="text"
|
|
||||||
v-for="cat in categories.data"
|
|
||||||
@change.stop="(e) => update(cat.name, e.target.value)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
FormControl,
|
|
||||||
createListResource,
|
|
||||||
createResource,
|
|
||||||
debounce,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import { Plus, X } from 'lucide-vue-next'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
const showForm = ref(false)
|
|
||||||
const category = ref(null)
|
|
||||||
const categoryInput = ref(null)
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const categories = createListResource({
|
|
||||||
doctype: 'LMS Category',
|
|
||||||
fields: ['name', 'category'],
|
|
||||||
auto: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const newCategory = createResource({
|
|
||||||
url: 'frappe.client.insert',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doc: {
|
|
||||||
doctype: 'LMS Category',
|
|
||||||
category: category.value,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const addCategory = () => {
|
|
||||||
newCategory.submit(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
onSuccess(data) {
|
|
||||||
categories.reload()
|
|
||||||
category.value = null
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const showCategoryForm = () => {
|
|
||||||
showForm.value = !showForm.value
|
|
||||||
setTimeout(() => {
|
|
||||||
categoryInput.value.$el.querySelector('input').focus()
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateCategory = createResource({
|
|
||||||
url: 'frappe.client.rename_doc',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doctype: 'LMS Category',
|
|
||||||
old_name: values.name,
|
|
||||||
new_name: values.category,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const update = (name, value) => {
|
|
||||||
updateCategory.submit(
|
|
||||||
{
|
|
||||||
name: name,
|
|
||||||
category: value,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess() {
|
|
||||||
categories.reload()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<Combobox v-model="selectedValue" nullable v-slot="{ open: isComboboxOpen }">
|
<div>
|
||||||
|
<div v-if="label" class="text-xs text-ink-gray-5 mb-1">
|
||||||
|
{{ __(label) }}
|
||||||
|
<span class="text-ink-red-3" v-if="attrs.required">*</span>
|
||||||
|
</div>
|
||||||
|
<Combobox
|
||||||
|
v-model="selectedValue"
|
||||||
|
nullable
|
||||||
|
v-slot="{ open: isComboboxOpen }"
|
||||||
|
>
|
||||||
<Popover class="w-full" v-model:show="showOptions">
|
<Popover class="w-full" v-model:show="showOptions">
|
||||||
<template #target="{ open: openPopover, togglePopover }">
|
<template #target="{ open: openPopover, togglePopover }">
|
||||||
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
||||||
@@ -8,6 +17,7 @@
|
|||||||
class="flex w-full items-center justify-between focus:outline-none"
|
class="flex w-full items-center justify-between focus:outline-none"
|
||||||
:class="inputClasses"
|
:class="inputClasses"
|
||||||
@click="() => togglePopover()"
|
@click="() => togglePopover()"
|
||||||
|
:disabled="attrs.readonly"
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<slot name="prefix" />
|
<slot name="prefix" />
|
||||||
@@ -28,7 +38,9 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #body="{ isOpen }">
|
<template #body="{ isOpen }">
|
||||||
<div v-show="isOpen">
|
<div v-show="isOpen">
|
||||||
<div class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2">
|
<div
|
||||||
|
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
|
||||||
|
>
|
||||||
<div class="relative px-1.5 pt-0.5">
|
<div class="relative px-1.5 pt-0.5">
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
ref="search"
|
ref="search"
|
||||||
@@ -122,6 +134,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</Popover>
|
</Popover>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -148,6 +161,10 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'md',
|
default: 'md',
|
||||||
},
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
variant: {
|
variant: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'subtle',
|
default: 'subtle',
|
||||||
|
|||||||
149
frontend/src/components/Controls/ChildTable.vue
Normal file
149
frontend/src/components/Controls/ChildTable.vue
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-ink-gray-5 mb-2">
|
||||||
|
{{ label }}
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto border rounded-md">
|
||||||
|
<div
|
||||||
|
class="grid items-center space-x-4 p-2 border-b"
|
||||||
|
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(column, index) in columns"
|
||||||
|
:key="index"
|
||||||
|
class="text-sm text-ink-gray-5"
|
||||||
|
>
|
||||||
|
{{ column }}
|
||||||
|
</div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(row, rowIndex) in rows"
|
||||||
|
:key="rowIndex"
|
||||||
|
class="grid items-center space-x-4 p-2"
|
||||||
|
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
||||||
|
>
|
||||||
|
<template v-for="key in Object.keys(row)" :key="key">
|
||||||
|
<input
|
||||||
|
v-if="showKey(key)"
|
||||||
|
v-model="row[key]"
|
||||||
|
class="py-1.5 px-2 border-none focus:ring-0 focus:border focus:border-gray-300 focus:bg-surface-gray-2 rounded-sm text-sm focus:outline-none"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="relative" ref="menuRef">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="(event: MouseEvent) => toggleMenu(rowIndex, event)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Ellipsis
|
||||||
|
class="size-4 text-ink-gray-7 stroke-1.5 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="menuOpenIndex === rowIndex"
|
||||||
|
class="absolute right-[30px] top-5 mt-1 w-32 bg-surface-white border border-outline-gray-1 rounded-md shadow-sm"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="deleteRow(rowIndex)"
|
||||||
|
class="flex items-center space-x-2 w-full text-left px-3 py-2 text-sm text-ink-red-3"
|
||||||
|
>
|
||||||
|
<Trash2 class="size-4 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ __('Delete') }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<Button @click="addRow">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="size-4 text-ink-gray-7" />
|
||||||
|
</template>
|
||||||
|
{{ __('Add Row') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { Button } from 'frappe-ui'
|
||||||
|
import { Ellipsis, Plus, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { onClickOutside } from '@vueuse/core'
|
||||||
|
|
||||||
|
const rows = defineModel<Cell[][]>()
|
||||||
|
const menuRef = ref(null)
|
||||||
|
const menuOpenIndex = ref<number | null>(null)
|
||||||
|
const menuTopPosition = ref<string>('')
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: Cell[][]): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
type Cell = {
|
||||||
|
value: string
|
||||||
|
editable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue?: Cell[][]
|
||||||
|
columns?: string[]
|
||||||
|
label?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
columns: [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = ref(props.columns)
|
||||||
|
|
||||||
|
watch(rows, () => {
|
||||||
|
if (rows.value?.length < 1) {
|
||||||
|
addRow()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const addRow = () => {
|
||||||
|
if (!rows.value) {
|
||||||
|
rows.value = []
|
||||||
|
}
|
||||||
|
let newRow: { [key: string]: string } = {}
|
||||||
|
columns.value.forEach((column: any) => {
|
||||||
|
newRow[column.toLowerCase().split(' ').join('_')] = ''
|
||||||
|
})
|
||||||
|
rows.value.push(newRow)
|
||||||
|
emit('update:modelValue', rows.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteRow = (index: number) => {
|
||||||
|
rows.value.splice(index, 1)
|
||||||
|
emit('update:modelValue', rows.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getGridTemplateColumns = () => {
|
||||||
|
return [...Array(columns.value.length).fill('1fr'), '0.25fr'].join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMenu = (index: number, event: MouseEvent) => {
|
||||||
|
menuOpenIndex.value = menuOpenIndex.value === index ? null : index
|
||||||
|
menuTopPosition.value = `${event.clientY + 10}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickOutside(menuRef, () => {
|
||||||
|
menuOpenIndex.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
const showKey = (key: string) => {
|
||||||
|
let columnsLower = columns.value.map((col) =>
|
||||||
|
col.toLowerCase().split(' ').join('_')
|
||||||
|
)
|
||||||
|
return columnsLower.includes(key)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
162
frontend/src/components/Controls/Code.vue
Normal file
162
frontend/src/components/Controls/Code.vue
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex w-full flex-col gap-1.5">
|
||||||
|
<div v-if="label" class="text-xs text-ink-gray-5">
|
||||||
|
{{ __(label) }}
|
||||||
|
</div>
|
||||||
|
<codemirror
|
||||||
|
v-model="code"
|
||||||
|
:extensions="extensions"
|
||||||
|
:tab-size="2"
|
||||||
|
:autofocus="autofocus"
|
||||||
|
:indent-with-tab="true"
|
||||||
|
:style="{ height: height, maxHeight: maxHeight }"
|
||||||
|
:disabled="readonly"
|
||||||
|
@blur="emitEditorValue"
|
||||||
|
:class="{
|
||||||
|
'border border-outline-gray-1': showBorder,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="showSaveButton"
|
||||||
|
@click="emit('save', code)"
|
||||||
|
class="mt-3 w-full text-base"
|
||||||
|
>
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, computed, watch } from 'vue'
|
||||||
|
import { Button } from 'frappe-ui'
|
||||||
|
import { Codemirror } from 'vue-codemirror'
|
||||||
|
import { autocompletion, closeBrackets } from '@codemirror/autocomplete'
|
||||||
|
import { LanguageSupport } from '@codemirror/language'
|
||||||
|
import { EditorView } from '@codemirror/view'
|
||||||
|
import { tomorrow } from 'thememirror'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
language: 'json' | 'javascript' | 'html' | 'css' | 'python'
|
||||||
|
modelValue: string | object | Array<string | object> | null
|
||||||
|
height?: string
|
||||||
|
maxHeight?: string
|
||||||
|
autofocus?: boolean
|
||||||
|
showSaveButton?: boolean
|
||||||
|
showLineNumbers?: boolean
|
||||||
|
completions?: Function | null
|
||||||
|
label?: string
|
||||||
|
showBorder?: boolean
|
||||||
|
required?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
language: 'javascript',
|
||||||
|
modelValue: null,
|
||||||
|
height: 'auto',
|
||||||
|
maxHeight: '250px',
|
||||||
|
showLineNumbers: true,
|
||||||
|
completions: null,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const emit = defineEmits(['update:modelValue', 'save'])
|
||||||
|
|
||||||
|
const code = ref<string>('')
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newVal) => {
|
||||||
|
code.value =
|
||||||
|
typeof newVal === 'string' ? newVal : JSON.stringify(newVal, null, 2)
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(code, (val) => {
|
||||||
|
emit('update:modelValue', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const emitEditorValue = () => {
|
||||||
|
try {
|
||||||
|
errorMessage.value = ''
|
||||||
|
let value = code.value || ''
|
||||||
|
|
||||||
|
if (!props.showSaveButton && !props.readonly) {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error while parsing JSON for editor', e)
|
||||||
|
errorMessage.value = `Invalid object/JSON: ${e.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageExtension = ref<LanguageSupport>()
|
||||||
|
const autocompleteExtension = ref()
|
||||||
|
|
||||||
|
async function setLanguageExtension() {
|
||||||
|
const importMap = {
|
||||||
|
json: () => import('@codemirror/lang-json'),
|
||||||
|
javascript: () => import('@codemirror/lang-javascript'),
|
||||||
|
html: () => import('@codemirror/lang-html'),
|
||||||
|
css: () => import('@codemirror/lang-css'),
|
||||||
|
python: () => import('@codemirror/lang-python'),
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageImport = importMap[props.language]
|
||||||
|
if (!languageImport) return
|
||||||
|
|
||||||
|
const module = await languageImport()
|
||||||
|
languageExtension.value = (module as any)[props.language]()
|
||||||
|
|
||||||
|
if (props.completions) {
|
||||||
|
const languageData = (module as any)[`${props.language}Language`]
|
||||||
|
autocompleteExtension.value = languageData.data.of({
|
||||||
|
autocomplete: props.completions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await setLanguageExtension()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.language,
|
||||||
|
async () => {
|
||||||
|
await setLanguageExtension()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const extensions = computed(() => {
|
||||||
|
const baseExtensions = [
|
||||||
|
closeBrackets(),
|
||||||
|
tomorrow,
|
||||||
|
EditorView.theme({
|
||||||
|
'&': {
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '12px',
|
||||||
|
},
|
||||||
|
'.cm-gutters': {
|
||||||
|
display: props.showLineNumbers ? 'flex' : 'none',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
if (languageExtension.value) {
|
||||||
|
baseExtensions.push(languageExtension.value)
|
||||||
|
}
|
||||||
|
if (autocompleteExtension.value) {
|
||||||
|
baseExtensions.push(autocompleteExtension.value)
|
||||||
|
}
|
||||||
|
const autocompletionOptions = {
|
||||||
|
activateOnTyping: true,
|
||||||
|
maxRenderedOptions: 10,
|
||||||
|
closeOnBlur: false,
|
||||||
|
icons: false,
|
||||||
|
optionClass: () => 'flex h-7 !px-2 items-center rounded !text-gray-600',
|
||||||
|
}
|
||||||
|
baseExtensions.push(autocompletion(autocompletionOptions))
|
||||||
|
return baseExtensions
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
height: height,
|
height: height,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<span class="text-xs text-ink-gray-7" v-if="label">
|
<span class="text-xs text-ink-gray-7 mb-1" v-if="label">
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
|
|||||||
108
frontend/src/components/Controls/ColorSwatches.vue
Normal file
108
frontend/src/components/Controls/ColorSwatches.vue
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-ink-gray-5 mb-1">
|
||||||
|
{{ __(label) }}
|
||||||
|
</div>
|
||||||
|
<Popover placement="bottom" class="!block">
|
||||||
|
<template #target="{ togglePopover, isOpen }">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<FormControl
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
class="w-full"
|
||||||
|
:placeholder="__('Set Color')"
|
||||||
|
@focus="togglePopover"
|
||||||
|
:modelValue="modelValue"
|
||||||
|
@update:modelValue="(val: string) => emit('update:modelValue', val)"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<div
|
||||||
|
class="size-4 rounded-full"
|
||||||
|
:style="
|
||||||
|
modelValue
|
||||||
|
? {
|
||||||
|
backgroundColor:
|
||||||
|
theme.backgroundColor[modelValue.toLowerCase()][400],
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Palette
|
||||||
|
v-if="!modelValue"
|
||||||
|
class="size-4 stroke-1.5 text-ink-gray-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #suffix>
|
||||||
|
<Button variant="ghost">
|
||||||
|
<X
|
||||||
|
class="size-3 text-ink-gray-5"
|
||||||
|
@click="emit('update:modelValue', null)"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #body="{ close }">
|
||||||
|
<div class="rounded-lg bg-surface-white p-3 border w-fit mt-2">
|
||||||
|
<div class="text-xs text-ink-gray-5 mb-1.5">
|
||||||
|
{{ __('Swatches') }}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-7 gap-2">
|
||||||
|
<div
|
||||||
|
v-for="color in colors"
|
||||||
|
:key="color"
|
||||||
|
class="size-5 rounded-full cursor-pointer"
|
||||||
|
:style="{
|
||||||
|
backgroundColor:
|
||||||
|
theme.backgroundColor[color.toLowerCase()][400],
|
||||||
|
}"
|
||||||
|
@click="
|
||||||
|
(e) => {
|
||||||
|
emit('update:modelValue', color)
|
||||||
|
close()
|
||||||
|
emit('change', color)
|
||||||
|
}
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Popover>
|
||||||
|
<div class="text-sm text-ink-gray-5 mt-2">
|
||||||
|
{{ description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Button, FormControl, Popover } from 'frappe-ui'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Palette, X } from 'lucide-vue-next'
|
||||||
|
import { theme } from '@/utils/theme'
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'change'])
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const colors = computed(() => {
|
||||||
|
return [
|
||||||
|
'Red',
|
||||||
|
'Blue',
|
||||||
|
'Green',
|
||||||
|
'Amber',
|
||||||
|
'Purple',
|
||||||
|
'Cyan',
|
||||||
|
'Orange',
|
||||||
|
'Violet',
|
||||||
|
'Pink',
|
||||||
|
'Teal',
|
||||||
|
'Gray',
|
||||||
|
'Yellow',
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
:variant="attrs.variant"
|
:variant="attrs.variant"
|
||||||
:placeholder="attrs.placeholder"
|
:placeholder="attrs.placeholder"
|
||||||
:filterable="false"
|
:filterable="false"
|
||||||
|
:readonly="attrs.readonly"
|
||||||
>
|
>
|
||||||
<template #target="{ open, togglePopover }">
|
<template #target="{ open, togglePopover }">
|
||||||
<slot name="target" v-bind="{ open, togglePopover }" />
|
<slot name="target" v-bind="{ open, togglePopover }" />
|
||||||
|
|||||||
@@ -55,9 +55,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ComboboxOption>
|
</ComboboxOption>
|
||||||
|
<div class="h-10"></div>
|
||||||
<div
|
<div
|
||||||
v-if="attrs.onCreate"
|
v-if="attrs.onCreate"
|
||||||
class="absolute bottom-2 left-1 w-[98%] pt-2 bg-white border-t"
|
class="absolute bottom-2 left-1 w-[99%] pt-2 bg-white border-t"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
76
frontend/src/components/Controls/Uploader.vue
Normal file
76
frontend/src/components/Controls/Uploader.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div v-if="label" class="text-xs text-ink-gray-5 mb-2">
|
||||||
|
{{ __(label) }}
|
||||||
|
<span class="text-ink-red-3">*</span>
|
||||||
|
</div>
|
||||||
|
<FileUploader
|
||||||
|
v-if="!modelValue"
|
||||||
|
:fileTypes="['image/*']"
|
||||||
|
:validateFile="validateFile"
|
||||||
|
@success="(file: File) => saveImage(file)"
|
||||||
|
>
|
||||||
|
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="border rounded-md w-fit py-7 px-20">
|
||||||
|
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<Button @click="openFileSelector">
|
||||||
|
{{ __('Upload') }}
|
||||||
|
</Button>
|
||||||
|
<div class="mt-1 text-ink-gray-5 text-sm leading-5">
|
||||||
|
{{ __(description) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FileUploader>
|
||||||
|
<div v-else class="mb-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<img :src="modelValue" class="border rounded-md w-44 h-auto" />
|
||||||
|
<div class="ml-4">
|
||||||
|
<Button @click="removeImage()">
|
||||||
|
{{ __('Remove') }}
|
||||||
|
</Button>
|
||||||
|
<div
|
||||||
|
v-if="description"
|
||||||
|
class="mt-2 text-ink-gray-5 text-sm leading-5"
|
||||||
|
>
|
||||||
|
{{ __(description) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { validateFile } from '@/utils'
|
||||||
|
import { Button, FileUploader } from 'frappe-ui'
|
||||||
|
import { Image } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
label?: string
|
||||||
|
description?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: '',
|
||||||
|
label: '',
|
||||||
|
description: '',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const saveImage = (file: any) => {
|
||||||
|
emit('update:modelValue', file.file_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeImage = () => {
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,41 +1,51 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="course.title"
|
v-if="course.title"
|
||||||
class="flex flex-col h-full rounded-md border-2 overflow-auto"
|
class="flex flex-col h-full rounded-md border-2 overflow-auto text-ink-gray-9"
|
||||||
style="min-height: 350px"
|
style="min-height: 350px"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="course-image"
|
class="w-[100%] h-[168px] bg-cover bg-center bg-no-repeat"
|
||||||
:class="{ 'default-image': !course.image }"
|
:style="
|
||||||
:style="{ backgroundImage: 'url(\'' + encodeURI(course.image) + '\')' }"
|
course.image
|
||||||
|
? { backgroundImage: `url('${encodeURI(course.image)}')` }
|
||||||
|
: {
|
||||||
|
backgroundImage: getGradientColor(),
|
||||||
|
backgroundBlendMode: 'screen',
|
||||||
|
}
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<div class="flex items-center flex-wrap relative top-4 px-2 w-fit">
|
<div class="flex items-center flex-wrap relative top-4 px-2 w-fit">
|
||||||
<Badge
|
<div
|
||||||
v-if="course.featured"
|
v-if="course.featured"
|
||||||
variant="subtle"
|
class="flex items-center space-x-1 text-xs text-ink-amber-3 bg-surface-white border border-outline-amber-1 px-2 py-0.5 rounded-md mr-1 mb-1"
|
||||||
theme="green"
|
|
||||||
size="md"
|
|
||||||
class="mb-1 mr-1"
|
|
||||||
>
|
>
|
||||||
|
<Star class="size-3 stroke-2" />
|
||||||
|
<span>
|
||||||
{{ __('Featured') }}
|
{{ __('Featured') }}
|
||||||
</Badge>
|
</span>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="course.tags"
|
v-if="course.tags"
|
||||||
v-for="tag in course.tags?.split(', ')"
|
v-for="tag in course.tags?.split(', ')"
|
||||||
class="text-xs bg-white text-gray-800 px-2 py-0.5 rounded-md mb-1 mr-1"
|
class="text-xs border bg-surface-white text-ink-gray-9 px-2 py-0.5 rounded-md mb-1 mr-1"
|
||||||
>
|
>
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!course.image" class="image-placeholder">
|
<div
|
||||||
{{ course.title[0] }}
|
v-if="!course.image"
|
||||||
|
class="flex items-center justify-center text-white flex-1 font-extrabold text-2xl my-auto px-5 text-center leading-6"
|
||||||
|
:class="course.tags ? 'h-[80%]' : 'h-full'"
|
||||||
|
>
|
||||||
|
{{ course.title }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col flex-auto p-4">
|
<div class="flex flex-col flex-auto p-4">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div v-if="course.lessons">
|
<div v-if="course.lessons">
|
||||||
<Tooltip :text="__('Lessons')">
|
<Tooltip :text="__('Lessons')">
|
||||||
<span class="flex items-center text-ink-gray-7">
|
<span class="flex items-center">
|
||||||
<BookOpen class="h-4 w-4 stroke-1.5 mr-1" />
|
<BookOpen class="h-4 w-4 stroke-1.5 mr-1" />
|
||||||
{{ course.lessons }}
|
{{ course.lessons }}
|
||||||
</span>
|
</span>
|
||||||
@@ -44,8 +54,8 @@
|
|||||||
|
|
||||||
<div v-if="course.enrollments">
|
<div v-if="course.enrollments">
|
||||||
<Tooltip :text="__('Enrolled Students')">
|
<Tooltip :text="__('Enrolled Students')">
|
||||||
<span class="flex items-center text-ink-gray-7">
|
<span class="flex items-center">
|
||||||
<Users class="h-4 w-4 stroke-1. mr-1" />
|
<Users class="h-4 w-4 stroke-1.5 mr-1" />
|
||||||
{{ course.enrollments }}
|
{{ course.enrollments }}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -53,14 +63,14 @@
|
|||||||
|
|
||||||
<div v-if="course.rating">
|
<div v-if="course.rating">
|
||||||
<Tooltip :text="__('Average Rating')">
|
<Tooltip :text="__('Average Rating')">
|
||||||
<span class="flex items-center text-ink-gray-7">
|
<span class="flex items-center">
|
||||||
<Star class="h-4 w-4 stroke-1.5 mr-1" />
|
<Star class="h-4 w-4 stroke-1.5 mr-1" />
|
||||||
{{ course.rating }}
|
{{ course.rating }}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="course.status != 'Approved'">
|
<!-- <div v-if="course.status != 'Approved'">
|
||||||
<Badge
|
<Badge
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
:theme="course.status === 'Under Review' ? 'orange' : 'blue'"
|
:theme="course.status === 'Under Review' ? 'orange' : 'blue'"
|
||||||
@@ -68,14 +78,14 @@
|
|||||||
>
|
>
|
||||||
{{ course.status }}
|
{{ course.status }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-xl font-semibold leading-6 text-ink-gray-9">
|
<div v-if="course.image" class="text-xl font-semibold leading-6">
|
||||||
{{ course.title }}
|
{{ course.title }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="short-introduction text-ink-gray-7 text-sm">
|
<div class="short-introduction text-sm">
|
||||||
{{ course.short_introduction }}
|
{{ course.short_introduction }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -84,11 +94,8 @@
|
|||||||
:progress="course.membership.progress"
|
:progress="course.membership.progress"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div v-if="user && course.membership" class="text-sm mt-2 mb-4">
|
||||||
v-if="user && course.membership"
|
{{ Math.ceil(course.membership.progress) }}% {{ __('completed') }}
|
||||||
class="text-sm text-ink-gray-7 mt-2 mb-4"
|
|
||||||
>
|
|
||||||
{{ Math.ceil(course.membership.progress) }}% completed
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between mt-auto">
|
<div class="flex items-center justify-between mt-auto">
|
||||||
@@ -108,21 +115,23 @@
|
|||||||
<div v-if="course.paid_course" class="font-semibold">
|
<div v-if="course.paid_course" class="font-semibold">
|
||||||
{{ course.price }}
|
{{ course.price }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
|
<Tooltip
|
||||||
v-if="course.paid_certificate || course.enable_certification"
|
v-if="course.paid_certificate || course.enable_certification"
|
||||||
class="text-xs text-ink-blue-3 bg-surface-blue-1 py-0.5 px-1 rounded-md"
|
:text="__('Get Certified')"
|
||||||
>
|
>
|
||||||
{{ __('Certification') }}
|
<GraduationCap class="size-5 stroke-1.5 text-ink-gray-7" />
|
||||||
</div>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
import { BookOpen, GraduationCap, Star, Users } from 'lucide-vue-next'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { Badge, Tooltip } from 'frappe-ui'
|
import { Tooltip } from 'frappe-ui'
|
||||||
|
import { theme } from '@/utils/theme'
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
import ProgressBar from '@/components/ProgressBar.vue'
|
import ProgressBar from '@/components/ProgressBar.vue'
|
||||||
|
|
||||||
@@ -134,16 +143,24 @@ const props = defineProps({
|
|||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const getGradientColor = () => {
|
||||||
|
let color = props.course.card_gradient?.toLowerCase() || 'blue'
|
||||||
|
let colorMap = theme.backgroundColor[color]
|
||||||
|
return `linear-gradient(to top right, black, ${colorMap[400]})`
|
||||||
|
/* return `bg-gradient-to-br from-${color}-100 via-${color}-200 to-${color}-400` */
|
||||||
|
/* return `linear-gradient(to bottom right, ${colorMap[100]}, ${colorMap[400]})` */
|
||||||
|
/* return `radial-gradient(ellipse at 80% 20%, black 20%, ${colorMap[500]} 100%)` */
|
||||||
|
/* return `radial-gradient(ellipse at 30% 70%, black 50%, ${colorMap[500]} 100%)` */
|
||||||
|
/* return `radial-gradient(ellipse at 80% 20%, ${colorMap[100]} 0%, ${colorMap[300]} 50%, ${colorMap[500]} 100%)` */
|
||||||
|
/* return `conic-gradient(from 180deg at 50% 50%, ${colorMap[100]} 0%, ${colorMap[200]} 50%, ${colorMap[400]} 100%)` */
|
||||||
|
/* return `linear-gradient(135deg, ${colorMap[100]}, ${colorMap[300]}), linear-gradient(120deg, rgba(255,255,255,0.4) 0%, transparent 60%) ` */
|
||||||
|
/* return `radial-gradient(circle at 20% 30%, ${colorMap[100]} 0%, transparent 40%),
|
||||||
|
radial-gradient(circle at 80% 40%, ${colorMap[200]} 0%, transparent 50%),
|
||||||
|
linear-gradient(135deg, ${colorMap[300]} 0%, ${colorMap[400]} 100%);` */
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.course-image {
|
|
||||||
height: 168px;
|
|
||||||
width: 100%;
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
.course-card-pills {
|
.course-card-pills {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
@@ -157,14 +174,6 @@ const props = defineProps({
|
|||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.default-image {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
background-color: theme('colors.green.100');
|
|
||||||
color: theme('colors.green.600');
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-group {
|
.avatar-group {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -173,14 +182,7 @@ const props = defineProps({
|
|||||||
.avatar-group .avatar {
|
.avatar-group .avatar {
|
||||||
transition: margin 0.1s ease-in-out;
|
transition: margin 0.1s ease-in-out;
|
||||||
}
|
}
|
||||||
.image-placeholder {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex: 1;
|
|
||||||
font-size: 5rem;
|
|
||||||
color: theme('colors.gray.700');
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.avatar-group.overlap .avatar + .avatar {
|
.avatar-group.overlap .avatar + .avatar {
|
||||||
margin-left: calc(-8px);
|
margin-left: calc(-8px);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="border-2 rounded-md min-w-80">
|
<div class="border-2 rounded-md min-w-80 max-w-sm">
|
||||||
<iframe
|
<iframe
|
||||||
v-if="course.data.video_link"
|
v-if="course.data.video_link"
|
||||||
:src="video_link"
|
:src="video_link"
|
||||||
@@ -26,6 +26,9 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Button variant="solid" size="md" class="w-full">
|
<Button variant="solid" size="md" class="w-full">
|
||||||
|
<template #prefix>
|
||||||
|
<BookText class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
<span>
|
<span>
|
||||||
{{ __('Continue Learning') }}
|
{{ __('Continue Learning') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -44,6 +47,9 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Button variant="solid" size="md" class="w-full">
|
<Button variant="solid" size="md" class="w-full">
|
||||||
|
<template #prefix>
|
||||||
|
<CreditCard class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
<span>
|
<span>
|
||||||
{{ __('Buy this course') }}
|
{{ __('Buy this course') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -57,12 +63,15 @@
|
|||||||
{{ __('Contact the Administrator to enroll for this course.') }}
|
{{ __('Contact the Administrator to enroll for this course.') }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
v-else
|
v-else-if="!user.data?.is_moderator && !is_instructor()"
|
||||||
@click="enrollStudent()"
|
@click="enrollStudent()"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<BookText class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
<span>
|
<span>
|
||||||
{{ __('Start Learning') }}
|
{{ __('Start Learning') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -74,8 +83,22 @@
|
|||||||
class="w-full mt-2"
|
class="w-full mt-2"
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<GraduationCap class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
{{ __('Get Certificate') }}
|
{{ __('Get Certificate') }}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="user.data?.is_moderator || is_instructor()"
|
||||||
|
class="w-full mt-2"
|
||||||
|
size="md"
|
||||||
|
@click="showProgressSummary"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<TrendingUp class="size-4 stroke-1.5" />
|
||||||
|
{{ __('Progress Summary') }}
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="user?.data?.is_moderator || is_instructor()"
|
v-if="user?.data?.is_moderator || is_instructor()"
|
||||||
:to="{
|
:to="{
|
||||||
@@ -86,6 +109,9 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Button variant="subtle" class="w-full mt-2" size="md">
|
<Button variant="subtle" class="w-full mt-2" size="md">
|
||||||
|
<template #prefix>
|
||||||
|
<Pencil class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
<span>
|
<span>
|
||||||
{{ __('Edit') }}
|
{{ __('Edit') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -116,7 +142,7 @@
|
|||||||
v-if="parseInt(course.data.rating) > 0"
|
v-if="parseInt(course.data.rating) > 0"
|
||||||
class="flex items-center text-ink-gray-9"
|
class="flex items-center text-ink-gray-9"
|
||||||
>
|
>
|
||||||
<Star class="h-4 w-4 stroke-1.5 fill-orange-500 text-gray-50" />
|
<Star class="size-4 stroke-1.5 fill-yellow-500 text-transparent" />
|
||||||
<span class="ml-2">
|
<span class="ml-2">
|
||||||
{{ course.data.rating }} {{ __('Rating') }}
|
{{ course.data.rating }} {{ __('Rating') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -142,18 +168,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<CourseProgressSummary
|
||||||
|
v-model="showProgressModal"
|
||||||
|
:courseName="course.data.name"
|
||||||
|
:enrollments="course.data.enrollments"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next'
|
import {
|
||||||
import { computed, inject } from 'vue'
|
BookOpen,
|
||||||
import { Badge, Button, createResource, toast } from 'frappe-ui'
|
BookText,
|
||||||
|
CreditCard,
|
||||||
|
GraduationCap,
|
||||||
|
Pencil,
|
||||||
|
Star,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { computed, inject, ref } from 'vue'
|
||||||
|
import { Badge, Button, call, createResource, toast } from 'frappe-ui'
|
||||||
import { formatAmount } from '@/utils/'
|
import { formatAmount } from '@/utils/'
|
||||||
import { capture } from '@/telemetry'
|
import { capture } from '@/telemetry'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import CertificationLinks from '@/components/CertificationLinks.vue'
|
import CertificationLinks from '@/components/CertificationLinks.vue'
|
||||||
|
import CourseProgressSummary from '@/components/Modals/CourseProgressSummary.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
const showProgressModal = ref(false)
|
||||||
const readOnlyMode = window.read_only_mode
|
const readOnlyMode = window.read_only_mode
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -175,13 +217,9 @@ function enrollStudent() {
|
|||||||
toast.success(__('You need to login first to enroll for this course'))
|
toast.success(__('You need to login first to enroll for this course'))
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
||||||
}, 1000)
|
}, 500)
|
||||||
} else {
|
} else {
|
||||||
const enrollStudentResource = createResource({
|
call('lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership', {
|
||||||
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
|
|
||||||
})
|
|
||||||
enrollStudentResource
|
|
||||||
.submit({
|
|
||||||
course: props.course.data.name,
|
course: props.course.data.name,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -198,7 +236,11 @@ function enrollStudent() {
|
|||||||
lessonNumber: 1,
|
lessonNumber: 1,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, 2000)
|
}, 1000)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
|
console.error(err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,4 +288,8 @@ const fetchCertificate = () => {
|
|||||||
member: user.data?.name,
|
member: user.data?.name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showProgressSummary = () => {
|
||||||
|
showProgressModal.value = true
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="text-ink-gray-7">
|
<div class="">
|
||||||
<span v-if="instructors?.length == 1">
|
<span v-if="instructors?.length == 1">
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
>
|
>
|
||||||
{{ instructors[0].first_name }}
|
{{ instructors[0].first_name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
and
|
{{ __('and') }}
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: 'Profile',
|
name: 'Profile',
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
>
|
>
|
||||||
{{ instructors[0].first_name }}
|
{{ instructors[0].first_name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
and {{ instructors?.length - 1 }} others
|
{{ __('and') }} {{ instructors?.length - 1 }} {{ __('others') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -23,13 +23,24 @@
|
|||||||
'border-2 rounded-md py-2 px-2': showOutline && outline.data?.length,
|
'border-2 rounded-md py-2 px-2': showOutline && outline.data?.length,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
|
<Draggable
|
||||||
|
:list="outline.data"
|
||||||
|
:disabled="!allowEdit"
|
||||||
|
item-key="name"
|
||||||
|
group="chapters"
|
||||||
|
@end="updateChapterOrder"
|
||||||
|
>
|
||||||
|
<template #item="{ element: chapter, index }">
|
||||||
|
<div class="chapter-item">
|
||||||
<Disclosure
|
<Disclosure
|
||||||
v-slot="{ open }"
|
v-slot="{ open }"
|
||||||
v-for="(chapter, index) in outline.data"
|
|
||||||
:key="chapter.name"
|
:key="chapter.name"
|
||||||
:defaultOpen="openChapterDetail(chapter.idx)"
|
:defaultOpen="openChapterDetail(chapter.idx)"
|
||||||
>
|
>
|
||||||
<DisclosureButton ref="" class="flex items-center w-full p-2 group">
|
<DisclosureButton
|
||||||
|
ref=""
|
||||||
|
class="flex items-center w-full p-2 group"
|
||||||
|
>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
:class="{
|
:class="{
|
||||||
'rotate-90 transform duration-200': open,
|
'rotate-90 transform duration-200': open,
|
||||||
@@ -105,7 +116,9 @@
|
|||||||
{{ lesson.title }}
|
{{ lesson.title }}
|
||||||
<Trash2
|
<Trash2
|
||||||
v-if="allowEdit"
|
v-if="allowEdit"
|
||||||
@click.prevent="trashLesson(lesson.name, chapter.name)"
|
@click.prevent="
|
||||||
|
trashLesson(lesson.name, chapter.name)
|
||||||
|
"
|
||||||
class="h-4 w-4 text-ink-red-3 ml-auto invisible group-hover:visible"
|
class="h-4 w-4 text-ink-red-3 ml-auto invisible group-hover:visible"
|
||||||
/>
|
/>
|
||||||
<Check
|
<Check
|
||||||
@@ -137,6 +150,9 @@
|
|||||||
</DisclosurePanel>
|
</DisclosurePanel>
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</Draggable>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChapterModal
|
<ChapterModal
|
||||||
v-if="user.data"
|
v-if="user.data"
|
||||||
@@ -148,7 +164,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Button, createResource, Tooltip, toast } from 'frappe-ui'
|
import { Button, createResource, Tooltip, toast } from 'frappe-ui'
|
||||||
import { getCurrentInstance, inject, ref } from 'vue'
|
import { getCurrentInstance, inject, ref, watch } from 'vue'
|
||||||
import Draggable from 'vuedraggable'
|
import Draggable from 'vuedraggable'
|
||||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
||||||
import {
|
import {
|
||||||
@@ -197,13 +213,22 @@ const props = defineProps({
|
|||||||
const outline = createResource({
|
const outline = createResource({
|
||||||
url: 'lms.lms.utils.get_course_outline',
|
url: 'lms.lms.utils.get_course_outline',
|
||||||
cache: ['course_outline', props.courseName],
|
cache: ['course_outline', props.courseName],
|
||||||
params: {
|
makeParams() {
|
||||||
|
return {
|
||||||
course: props.courseName,
|
course: props.courseName,
|
||||||
progress: props.getProgress,
|
progress: props.getProgress,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.courseName,
|
||||||
|
() => {
|
||||||
|
outline.reload()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const deleteLesson = createResource({
|
const deleteLesson = createResource({
|
||||||
url: 'lms.lms.api.delete_lesson',
|
url: 'lms.lms.api.delete_lesson',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
@@ -233,6 +258,20 @@ const updateLessonIndex = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const updateChapterIndex = createResource({
|
||||||
|
url: 'lms.lms.api.update_chapter_index',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
chapter: values.chapter,
|
||||||
|
course: values.course,
|
||||||
|
idx: values.idx,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
toast.success(__('Chapter moved successfully'))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const trashLesson = (lessonName, chapterName) => {
|
const trashLesson = (lessonName, chapterName) => {
|
||||||
$dialog({
|
$dialog({
|
||||||
title: __('Delete this lesson?'),
|
title: __('Delete this lesson?'),
|
||||||
@@ -278,6 +317,14 @@ const updateOutline = (e) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateChapterOrder = (e) => {
|
||||||
|
updateChapterIndex.submit({
|
||||||
|
chapter: e.item.__draggable_context.element.name,
|
||||||
|
course: props.courseName,
|
||||||
|
idx: e.newIndex,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const deleteChapter = createResource({
|
const deleteChapter = createResource({
|
||||||
url: 'lms.lms.api.delete_chapter',
|
url: 'lms.lms.api.delete_chapter',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
|
|||||||
@@ -35,14 +35,14 @@
|
|||||||
<span class="text-ink-gray-7">
|
<span class="text-ink-gray-7">
|
||||||
{{ review.creation }}
|
{{ review.creation }}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex mt-2">
|
<div class="flex mt-2 space-x-1">
|
||||||
<Star
|
<Star
|
||||||
v-for="index in 5"
|
v-for="index in 5"
|
||||||
class="h-5 w-5 text-ink-gray-1 rounded-sm mr-2"
|
class="size-4 text-transparent rounded-sm"
|
||||||
:class="
|
:class="
|
||||||
index <= Math.ceil(review.rating)
|
index <= Math.ceil(review.rating)
|
||||||
? 'fill-orange-500'
|
? 'fill-yellow-500'
|
||||||
: 'fill-gray-600'
|
: 'fill-gray-300'
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { Star } from 'lucide-vue-next'
|
import { Star } from 'lucide-vue-next'
|
||||||
import { createResource, Button } from 'frappe-ui'
|
import { createResource, Button } from 'frappe-ui'
|
||||||
import { computed, ref, inject } from 'vue'
|
import { watch, ref, inject } from 'vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import ReviewModal from '@/components/Modals/ReviewModal.vue'
|
import ReviewModal from '@/components/Modals/ReviewModal.vue'
|
||||||
|
|
||||||
@@ -101,12 +101,21 @@ const hasReviewed = createResource({
|
|||||||
const reviews = createResource({
|
const reviews = createResource({
|
||||||
url: 'lms.lms.utils.get_reviews',
|
url: 'lms.lms.utils.get_reviews',
|
||||||
cache: ['course_reviews', props.courseName],
|
cache: ['course_reviews', props.courseName],
|
||||||
params: {
|
makeParams() {
|
||||||
|
return {
|
||||||
course: props.courseName,
|
course: props.courseName,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.courseName,
|
||||||
|
() => {
|
||||||
|
reviews.reload()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const showReviewModal = ref(false)
|
const showReviewModal = ref(false)
|
||||||
|
|
||||||
function openReviewModal() {
|
function openReviewModal() {
|
||||||
|
|||||||
@@ -94,10 +94,10 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
|
import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
|
||||||
import { timeAgo } from '../utils'
|
import { timeAgo } from '@/utils'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
||||||
import { ref, inject, onMounted } from 'vue'
|
import { ref, inject, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
const showTopics = defineModel('showTopics')
|
const showTopics = defineModel('showTopics')
|
||||||
const newReply = ref('')
|
const newReply = ref('')
|
||||||
@@ -251,4 +251,10 @@ const deleteReply = (reply) => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
socket.off('publish_message')
|
||||||
|
socket.off('update_message')
|
||||||
|
socket.off('delete_message')
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -69,8 +69,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { createResource, Button } from 'frappe-ui'
|
import { createResource, Button } from 'frappe-ui'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { singularize, timeAgo } from '../utils'
|
import { singularize, timeAgo } from '@/utils'
|
||||||
import { ref, onMounted, inject } from 'vue'
|
import { ref, onMounted, inject, onUnmounted } from 'vue'
|
||||||
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
||||||
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
||||||
import { MessageSquareText } from 'lucide-vue-next'
|
import { MessageSquareText } from 'lucide-vue-next'
|
||||||
@@ -153,4 +153,8 @@ const showReplies = (topic) => {
|
|||||||
const openTopicModal = () => {
|
const openTopicModal = () => {
|
||||||
showTopicModal.value = true
|
showTopicModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
socket.off('new_discussion_topic')
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
|
||||||
{{ __(label) }}
|
|
||||||
</div>
|
|
||||||
<!-- <div class="text-xs text-ink-gray-5">
|
|
||||||
{{ __(description) }}
|
|
||||||
</div> -->
|
|
||||||
</div>
|
|
||||||
<div class="flex item-center space-x-2">
|
|
||||||
<FormControl
|
|
||||||
v-model="search"
|
|
||||||
:placeholder="__('Search')"
|
|
||||||
type="text"
|
|
||||||
:debounce="300"
|
|
||||||
/>
|
|
||||||
<Button @click="() => (showForm = !showForm)">
|
|
||||||
<template #prefix>
|
|
||||||
<Plus v-if="!showForm" class="size-4 stroke-1.5" />
|
|
||||||
<X v-else class="size-4 stroke-1.5" />
|
|
||||||
</template>
|
|
||||||
{{ showForm ? __('Close') : __('New') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Form to add new member -->
|
|
||||||
<div v-if="showForm" class="flex items-center space-x-2 my-4">
|
|
||||||
<FormControl
|
|
||||||
v-model="email"
|
|
||||||
:placeholder="__('Email')"
|
|
||||||
type="email"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<Button @click="addEvaluator()" variant="subtle">
|
|
||||||
{{ __('Add') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divide-y">
|
|
||||||
<div
|
|
||||||
v-for="evaluator in evaluators.data"
|
|
||||||
@click="openProfile(evaluator.username)"
|
|
||||||
class="cursor-pointer"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between py-3">
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<Avatar
|
|
||||||
:image="evaluator.user_image"
|
|
||||||
:label="evaluator.full_name"
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div class="text-base font-semibold text-ink-gray-9">
|
|
||||||
{{ evaluator.full_name }}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-ink-gray-5">
|
|
||||||
{{ evaluator.evaluator }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { createResource, Button, FormControl, call, Avatar } from 'frappe-ui'
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { Plus, X } from 'lucide-vue-next'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
const show = defineModel('show')
|
|
||||||
const search = ref('')
|
|
||||||
const showForm = ref(false)
|
|
||||||
const email = ref('')
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
show: {
|
|
||||||
type: Boolean,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const evaluators = createResource({
|
|
||||||
url: 'frappe.client.get_list',
|
|
||||||
makeParams: () => {
|
|
||||||
return {
|
|
||||||
doctype: 'Course Evaluator',
|
|
||||||
fields: ['evaluator', 'full_name', 'user_image', 'username'],
|
|
||||||
filters: search.value ? { evaluator: ['like', `%${search.value}%`] } : {},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
auto: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const addEvaluator = () => {
|
|
||||||
call('lms.lms.api.add_an_evaluator', {
|
|
||||||
email: email.value,
|
|
||||||
}).then((data) => {
|
|
||||||
showForm.value = false
|
|
||||||
email.value = ''
|
|
||||||
evaluators.reload()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(search, () => {
|
|
||||||
evaluators.reload()
|
|
||||||
})
|
|
||||||
|
|
||||||
const openProfile = (username) => {
|
|
||||||
show.value = false
|
|
||||||
router.push({
|
|
||||||
name: 'Profile',
|
|
||||||
params: {
|
|
||||||
username: username,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-4"
|
class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-3"
|
||||||
>
|
>
|
||||||
<div class="flex space-x-4 mb-4">
|
<div class="flex space-x-4 mb-4">
|
||||||
<div class="flex flex-col space-y-2 flex-1">
|
<div class="flex flex-col space-y-2 flex-1">
|
||||||
|
|||||||
@@ -15,60 +15,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2" v-for="(item, key) in contentMap" :key="key">
|
||||||
<div
|
<div
|
||||||
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
|
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
|
||||||
@click="openHelpDialog('quiz')"
|
@click="openHelpDialog(key)"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{{ __('How to add a Quiz?') }}
|
{{ __(item.title) }}
|
||||||
</span>
|
</span>
|
||||||
<Info class="w-3 h-3 text-ink-gray-7" />
|
<Info class="w-3 h-3 text-ink-gray-7" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
|
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
|
||||||
{{
|
{{ __(item.description) }}
|
||||||
__(
|
|
||||||
'Click on the add icon in the editor and select Quiz from the menu. It opens up a dialog, where you can either select a quiz from the list or create a new quiz. When you select the Create New option it redirects you to the quiz creation page.'
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div
|
|
||||||
class="flex text-sm font-medium space-x-2 cursor-pointer"
|
|
||||||
@click="openHelpDialog('upload')"
|
|
||||||
>
|
|
||||||
<span class="leading-5">
|
|
||||||
{{ __(contentMap['upload']) }}
|
|
||||||
</span>
|
|
||||||
<Info class="w-3 h-3 text-ink-gray-7" />
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
|
|
||||||
{{
|
|
||||||
__(
|
|
||||||
'To upload Image, Video, Audio or PDF from your system, click on the add icon and select upload from the menu. Then choose the file you want to add to the lesson and it gets added to your lesson.'
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div
|
|
||||||
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
|
|
||||||
@click="openHelpDialog('youtube')"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{{ __(contentMap['youtube']) }}
|
|
||||||
</span>
|
|
||||||
<Info class="w-3 h-3 text-ink-gray-7" />
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
|
|
||||||
{{
|
|
||||||
__(
|
|
||||||
'Copy the URL of the video from YouTube and paste it in the editor.'
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,14 +41,31 @@ const showExplanation = ref(false)
|
|||||||
const type = ref(null)
|
const type = ref(null)
|
||||||
const title = ref(null)
|
const title = ref(null)
|
||||||
const contentMap = {
|
const contentMap = {
|
||||||
quiz: 'How to add a Quiz?',
|
quiz: {
|
||||||
upload: 'How to upload content from your system?',
|
title: 'How to add a Quiz?',
|
||||||
youtube: 'How to add a YouTube Video?',
|
description:
|
||||||
|
'Click on the add icon in the editor and select Quiz from the menu. It opens up a dialog, where you can either select a quiz from the list or create a new quiz. When you select the Create New option it redirects you to the quiz creation page.',
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
title: 'How to upload content from your system?',
|
||||||
|
description:
|
||||||
|
'To upload Image, Video, Audio or PDF from your system, click on the add icon and select upload from the menu. Then choose the file you want to add to the lesson and it gets added to your lesson.',
|
||||||
|
},
|
||||||
|
youtube: {
|
||||||
|
title: 'How to add a YouTube Video?',
|
||||||
|
description:
|
||||||
|
'Copy the URL of the video from YouTube and paste it in the editor.',
|
||||||
|
},
|
||||||
|
remove: {
|
||||||
|
title: 'How to remove an embed?',
|
||||||
|
description:
|
||||||
|
'To remove an embed like YouTube or Vimeo, put your cursor on the line below the embed, then drag your mouse cursor upwards to select the embed. Once the embed is selected press BackSpace.',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const openHelpDialog = (contentType) => {
|
const openHelpDialog = (contentType) => {
|
||||||
type.value = contentType
|
type.value = contentType
|
||||||
title.value = contentMap[contentType]
|
title.value = contentMap[contentType].title
|
||||||
showExplanation.value = true
|
showExplanation.value = true
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-between mb-5">
|
<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"
|
||||||
|
>
|
||||||
|
<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">
|
<div class="text-lg font-semibold text-ink-gray-9">
|
||||||
{{ __('Live Class') }}
|
{{ __('Live Class') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -12,10 +22,18 @@
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
|
<div v-if="liveClasses.data?.length" class="grid grid-cols-3 gap-5 mt-5">
|
||||||
<div
|
<div
|
||||||
v-for="cls in liveClasses.data"
|
v-for="cls in liveClasses.data"
|
||||||
class="flex flex-col border rounded-md h-full text-ink-gray-7 p-3"
|
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">
|
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
|
||||||
{{ cls.title }}
|
{{ cls.title }}
|
||||||
@@ -23,7 +41,7 @@
|
|||||||
<div class="short-introduction">
|
<div class="short-introduction">
|
||||||
{{ cls.description }}
|
{{ cls.description }}
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
<div class="mt-auto space-y-3">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||||
<span>
|
<span>
|
||||||
@@ -33,18 +51,20 @@
|
|||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<Clock class="w-4 h-4 stroke-1.5" />
|
<Clock class="w-4 h-4 stroke-1.5" />
|
||||||
<span>
|
<span>
|
||||||
{{ formatTime(cls.time) }}
|
{{ formatTime(cls.time) }} -
|
||||||
|
{{ dayjs(getClassEnd(cls)).format('HH:mm') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="cls.date >= dayjs().format('YYYY-MM-DD')"
|
v-if="canAccessClass(cls)"
|
||||||
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
|
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
||||||
:href="cls.start_url"
|
:href="cls.start_url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="w-1/2 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="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" />
|
<Monitor class="h-4 w-4 stroke-1.5" />
|
||||||
{{ __('Start') }}
|
{{ __('Start') }}
|
||||||
@@ -58,42 +78,63 @@
|
|||||||
{{ __('Join') }}
|
{{ __('Join') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex items-center space-x-2 text-yellow-700">
|
<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" />
|
<Info class="w-4 h-4 stroke-1.5" />
|
||||||
<span>
|
<span>
|
||||||
{{ __('This class has ended') }}
|
{{ __('Ended') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-sm italic text-ink-gray-5">
|
<div v-else class="text-sm italic text-ink-gray-5 mt-2">
|
||||||
{{ __('No live classes scheduled') }}
|
{{ __('No live classes scheduled') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LiveClassModal
|
<LiveClassModal
|
||||||
:batch="props.batch"
|
:batch="props.batch"
|
||||||
|
:zoomAccount="props.zoomAccount"
|
||||||
v-model="showLiveClassModal"
|
v-model="showLiveClassModal"
|
||||||
v-model:reloadLiveClasses="liveClasses"
|
v-model:reloadLiveClasses="liveClasses"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<LiveClassAttendance v-model="showAttendance" :live_class="attendanceFor" />
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createListResource, Button } from 'frappe-ui'
|
import { createListResource, Button, Tooltip } from 'frappe-ui'
|
||||||
import { Plus, Clock, Calendar, Video, Monitor, Info } from 'lucide-vue-next'
|
import {
|
||||||
import { inject } from 'vue'
|
Plus,
|
||||||
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
|
Clock,
|
||||||
import { ref } from 'vue'
|
Calendar,
|
||||||
|
Video,
|
||||||
|
Monitor,
|
||||||
|
Info,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { inject, ref } from 'vue'
|
||||||
import { formatTime } from '@/utils/'
|
import { formatTime } from '@/utils/'
|
||||||
|
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
|
||||||
|
import LiveClassAttendance from '@/components/Modals/LiveClassAttendance.vue'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const showLiveClassModal = ref(false)
|
const showLiveClassModal = ref(false)
|
||||||
const dayjs = inject('$dayjs')
|
const dayjs = inject('$dayjs')
|
||||||
const readOnlyMode = window.read_only_mode
|
const readOnlyMode = window.read_only_mode
|
||||||
|
const showAttendance = ref(false)
|
||||||
|
const attendanceFor = ref(null)
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
zoomAccount: String,
|
||||||
})
|
})
|
||||||
|
|
||||||
const liveClasses = createListResource({
|
const liveClasses = createListResource({
|
||||||
@@ -106,6 +147,8 @@ const liveClasses = createListResource({
|
|||||||
'description',
|
'description',
|
||||||
'time',
|
'time',
|
||||||
'date',
|
'date',
|
||||||
|
'duration',
|
||||||
|
'attendees',
|
||||||
'start_url',
|
'start_url',
|
||||||
'join_url',
|
'join_url',
|
||||||
'owner',
|
'owner',
|
||||||
@@ -120,8 +163,38 @@ const openLiveClassModal = () => {
|
|||||||
|
|
||||||
const canCreateClass = () => {
|
const canCreateClass = () => {
|
||||||
if (readOnlyMode) return false
|
if (readOnlyMode) return false
|
||||||
|
if (!props.zoomAccount) return false
|
||||||
|
return hasPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPermission = () => {
|
||||||
return user.data?.is_moderator || user.data?.is_evaluator
|
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 getClassEnd = (cls) => {
|
||||||
|
const classStart = new Date(`${cls.date}T${cls.time}`)
|
||||||
|
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>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.short-introduction {
|
.short-introduction {
|
||||||
|
|||||||
@@ -1,16 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col">
|
<div class="flex h-full flex-col relative">
|
||||||
<div class="h-full pb-10" id="scrollContainer">
|
<div class="h-full pb-10" id="scrollContainer">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="relative z-20">
|
||||||
|
<!-- Dropdown menu -->
|
||||||
|
<div
|
||||||
|
class="fixed bottom-16 right-2 w-[80%] rounded-md bg-surface-white text-base p-5 space-y-4 shadow-md"
|
||||||
|
v-if="showMenu"
|
||||||
|
ref="menu"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="link in otherLinks"
|
||||||
|
:key="link.label"
|
||||||
|
class="flex items-center space-x-2 cursor-pointer"
|
||||||
|
@click="handleClick(link)"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="icons[link.icon]"
|
||||||
|
class="h-4 w-4 stroke-1.5 text-ink-gray-5"
|
||||||
|
/>
|
||||||
|
<div>{{ link.label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fixed menu -->
|
||||||
<div
|
<div
|
||||||
v-if="sidebarSettings.data"
|
v-if="sidebarSettings.data"
|
||||||
class="fixed flex items-center justify-around border-t border-outline-gray-2 bottom-0 z-10 w-full bg-surface-white standalone:pb-4"
|
class="fixed bottom-0 left-0 w-full flex items-center justify-around border-t border-outline-gray-2 bg-surface-white standalone:pb-4 z-10"
|
||||||
:style="{
|
|
||||||
gridTemplateColumns: `repeat(${
|
|
||||||
sidebarLinks.length + 1
|
|
||||||
}, minmax(0, 1fr))`,
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
v-for="tab in sidebarLinks"
|
v-for="tab in sidebarLinks"
|
||||||
@@ -25,60 +43,64 @@
|
|||||||
:class="[isActive(tab) ? 'text-ink-gray-9' : 'text-ink-gray-5']"
|
:class="[isActive(tab) ? 'text-ink-gray-9' : 'text-ink-gray-5']"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<Popover
|
<button @click="toggleMenu">
|
||||||
trigger="hover"
|
|
||||||
popoverClass="bottom-28 mx-2"
|
|
||||||
placement="top-start"
|
|
||||||
>
|
|
||||||
<template #target>
|
|
||||||
<component
|
<component
|
||||||
:is="icons['List']"
|
:is="icons['List']"
|
||||||
class="h-6 w-6 stroke-1.5 text-ink-gray-5"
|
class="h-6 w-6 stroke-1.5 text-ink-gray-5"
|
||||||
/>
|
/>
|
||||||
</template>
|
</button>
|
||||||
<template #body-main>
|
|
||||||
<div class="text-base p-5 space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="link in otherLinks"
|
|
||||||
:key="link.label"
|
|
||||||
class="flex items-center space-x-2"
|
|
||||||
@click="handleClick(link)"
|
|
||||||
>
|
|
||||||
<component
|
|
||||||
:is="icons[link.icon]"
|
|
||||||
class="h-4 w-4 stroke-1.5 text-ink-gray-5"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
{{ link.label }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { getSidebarLinks } from '../utils'
|
import { getSidebarLinks } from '@/utils'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { watch, ref, onMounted } from 'vue'
|
import { watch, ref, onMounted } from 'vue'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import { useSettings } from '@/stores/settings'
|
||||||
import { usersStore } from '@/stores/user'
|
import { usersStore } from '@/stores/user'
|
||||||
import { Popover } from 'frappe-ui'
|
|
||||||
import * as icons from 'lucide-vue-next'
|
import * as icons from 'lucide-vue-next'
|
||||||
|
|
||||||
const { logout, user, sidebarSettings } = sessionStore()
|
const { logout, user } = sessionStore()
|
||||||
let { isLoggedIn } = sessionStore()
|
let { isLoggedIn } = sessionStore()
|
||||||
|
const { sidebarSettings } = useSettings()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
let { userResource } = usersStore()
|
let { userResource } = usersStore()
|
||||||
const sidebarLinks = ref(getSidebarLinks())
|
const sidebarLinks = ref(getSidebarLinks())
|
||||||
const otherLinks = ref([])
|
const otherLinks = ref([])
|
||||||
|
const showMenu = ref(false)
|
||||||
|
const menu = ref(null)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
sidebarSettings.reload(
|
sidebarSettings.reload(
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
|
filterLinksToShow(data)
|
||||||
|
addOtherLinks()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleOutsideClick = (e) => {
|
||||||
|
if (menu.value && !menu.value.contains(e.target)) {
|
||||||
|
showMenu.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(showMenu, (val) => {
|
||||||
|
if (val) {
|
||||||
|
setTimeout(() => {
|
||||||
|
document.addEventListener('click', handleOutsideClick)
|
||||||
|
}, 0)
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('click', handleOutsideClick)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const filterLinksToShow = (data) => {
|
||||||
Object.keys(data).forEach((key) => {
|
Object.keys(data).forEach((key) => {
|
||||||
if (!parseInt(data[key])) {
|
if (!parseInt(data[key])) {
|
||||||
sidebarLinks.value = sidebarLinks.value.filter(
|
sidebarLinks.value = sidebarLinks.value.filter(
|
||||||
@@ -86,12 +108,7 @@ onMounted(() => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
addOtherLinks()
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const addOtherLinks = () => {
|
const addOtherLinks = () => {
|
||||||
if (user) {
|
if (user) {
|
||||||
@@ -122,6 +139,7 @@ watch(userResource, () => {
|
|||||||
(userResource.data.is_moderator || userResource.data.is_instructor)
|
(userResource.data.is_moderator || userResource.data.is_instructor)
|
||||||
) {
|
) {
|
||||||
addQuizzes()
|
addQuizzes()
|
||||||
|
addAssignments()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -133,6 +151,14 @@ const addQuizzes = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addAssignments = () => {
|
||||||
|
otherLinks.value.push({
|
||||||
|
label: 'Assignments',
|
||||||
|
icon: 'Pencil',
|
||||||
|
to: 'Assignments',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
let isActive = (tab) => {
|
let isActive = (tab) => {
|
||||||
return tab.activeFor?.includes(router.currentRoute.value.name)
|
return tab.activeFor?.includes(router.currentRoute.value.name)
|
||||||
}
|
}
|
||||||
@@ -158,4 +184,8 @@ const isVisible = (tab) => {
|
|||||||
else if (tab.label == 'Log out') return isLoggedIn
|
else if (tab.label == 'Log out') return isLoggedIn
|
||||||
else return true
|
else return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleMenu = () => {
|
||||||
|
showMenu.value = !showMenu.value
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
<div class="">
|
<div class="">
|
||||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||||
{{ __('Reply To') }}
|
{{ __('Reply To') }}
|
||||||
|
<span class="text-ink-red-3">*</span>
|
||||||
</div>
|
</div>
|
||||||
<Input type="text" v-model="announcement.replyTo" />
|
<Input type="text" v-model="announcement.replyTo" />
|
||||||
</div>
|
</div>
|
||||||
@@ -70,8 +71,8 @@ const announcementResource = createResource({
|
|||||||
url: 'frappe.core.doctype.communication.email.make',
|
url: 'frappe.core.doctype.communication.email.make',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
return {
|
return {
|
||||||
recipients: props.students.join(', '),
|
recipients: announcement.replyTo,
|
||||||
cc: announcement.replyTo,
|
bcc: props.students.join(', '),
|
||||||
subject: announcement.subject,
|
subject: announcement.subject,
|
||||||
content: announcement.announcement,
|
content: announcement.announcement,
|
||||||
doctype: 'LMS Batch',
|
doctype: 'LMS Batch',
|
||||||
@@ -95,6 +96,9 @@ const makeAnnouncement = (close) => {
|
|||||||
if (!announcement.announcement) {
|
if (!announcement.announcement) {
|
||||||
return __('Announcement is required')
|
return __('Announcement is required')
|
||||||
}
|
}
|
||||||
|
if (!announcement.replyTo) {
|
||||||
|
return __('Reply To is required')
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
close()
|
close()
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ const assessmentTypes = computed(() => {
|
|||||||
return [
|
return [
|
||||||
{ label: 'Quiz', value: 'LMS Quiz' },
|
{ label: 'Quiz', value: 'LMS Quiz' },
|
||||||
{ label: 'Assignment', value: 'LMS Assignment' },
|
{ label: 'Assignment', value: 'LMS Assignment' },
|
||||||
|
{ label: 'Programming Exercise', value: 'LMS Programming Exercise' },
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="p-5 text-base max-h-[75vh] overflow-y-auto">
|
<div class="p-5 text-base">
|
||||||
<div class="text-lg text-ink-gray-9 font-semibold mb-5">
|
<div class="text-lg text-ink-gray-9 font-semibold mb-5">
|
||||||
{{
|
{{
|
||||||
assignmentID === 'new'
|
assignmentID === 'new'
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
: __('Edit Assignment')
|
: __('Edit Assignment')
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4 max-h-[75vh] overflow-y-auto">
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="assignment.title"
|
v-model="assignment.title"
|
||||||
:label="__('Title')"
|
:label="__('Title')"
|
||||||
|
|||||||
230
frontend/src/components/Modals/CourseProgressSummary.vue
Normal file
230
frontend/src/components/Modals/CourseProgressSummary.vue
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title: __('Course Progress Summary'),
|
||||||
|
size: '5xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="flex justify-between space-x-10 text-base mt-10">
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="flex items-center justify-between space-x-5 mb-4">
|
||||||
|
<!-- <div class="text-xl font-semibold text-ink-gray-6">
|
||||||
|
{{ __('{0} Members').format(memberCount) }}
|
||||||
|
</div> -->
|
||||||
|
<FormControl
|
||||||
|
v-model="searchFilter"
|
||||||
|
:placeholder="__('Search by Member')"
|
||||||
|
type="text"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-[70vh] overflow-y-auto">
|
||||||
|
<ListView
|
||||||
|
v-if="progressList.loading || progressList.data?.length"
|
||||||
|
:columns="progressColumns"
|
||||||
|
:rows="progressList.data"
|
||||||
|
rowKey="name"
|
||||||
|
:options="{
|
||||||
|
selectable: false,
|
||||||
|
showTooltip: false,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem
|
||||||
|
:item="item"
|
||||||
|
v-for="item in progressColumns"
|
||||||
|
:key="item.key"
|
||||||
|
>
|
||||||
|
<template #prefix="{ item }">
|
||||||
|
<FeatherIcon
|
||||||
|
:name="item.icon?.toString()"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ListHeaderItem>
|
||||||
|
</ListHeader>
|
||||||
|
<ListRows v-for="row in progressList.data">
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'Profile',
|
||||||
|
params: { username: row.member_username },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListRow :row="row">
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem
|
||||||
|
:item="row[column.key]"
|
||||||
|
:align="column.align"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<div v-if="column.key == 'member_name'">
|
||||||
|
<Avatar
|
||||||
|
class="flex items-center"
|
||||||
|
:image="row['member_image']"
|
||||||
|
:label="item"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div>
|
||||||
|
{{ row[column.key].toString() }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</router-link>
|
||||||
|
</ListRows>
|
||||||
|
</ListView>
|
||||||
|
<div
|
||||||
|
v-if="progressList.data && progressList.hasNextPage"
|
||||||
|
class="flex justify-center my-5"
|
||||||
|
>
|
||||||
|
<Button @click="progressList.next()">
|
||||||
|
{{ __('Load More') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4 self-start w-full space-y-5">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<NumberChart
|
||||||
|
class="border rounded-md w-full"
|
||||||
|
:config="{
|
||||||
|
title: __('Enrollments'),
|
||||||
|
value: memberCount || 0,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<NumberChart
|
||||||
|
class="border rounded-md w-full"
|
||||||
|
:config="{
|
||||||
|
title: __('Average Progress %'),
|
||||||
|
value: chartDetails.data?.average_progress || 0,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DonutChart
|
||||||
|
:config="{
|
||||||
|
data: chartDetails.data?.progress_distribution || [],
|
||||||
|
title: __('Progress Distribution'),
|
||||||
|
categoryColumn: 'category',
|
||||||
|
valueColumn: 'count',
|
||||||
|
colors: [
|
||||||
|
theme.colors.red['400'],
|
||||||
|
theme.colors.amber['400'],
|
||||||
|
theme.colors.pink['400'],
|
||||||
|
theme.colors.blue['400'],
|
||||||
|
theme.colors.green['400'],
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
createListResource,
|
||||||
|
createResource,
|
||||||
|
Dialog,
|
||||||
|
DonutChart,
|
||||||
|
FeatherIcon,
|
||||||
|
FormControl,
|
||||||
|
ListView,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListRows,
|
||||||
|
ListRow,
|
||||||
|
ListRowItem,
|
||||||
|
NumberChart,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { theme } from '@/utils/theme'
|
||||||
|
|
||||||
|
const show = defineModel<boolean | undefined>()
|
||||||
|
const searchFilter = ref<string | null>(null)
|
||||||
|
type Filters = {
|
||||||
|
course: string | undefined
|
||||||
|
member_name?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
courseName?: string
|
||||||
|
enrollments?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const memberCount = ref<number>(props.enrollments || 0)
|
||||||
|
|
||||||
|
const chartDetails = createResource({
|
||||||
|
url: 'lms.lms.api.get_course_progress_distribution',
|
||||||
|
params: {
|
||||||
|
course: props.courseName,
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const progressList = createListResource({
|
||||||
|
doctype: 'LMS Enrollment',
|
||||||
|
filters: {
|
||||||
|
course: props.courseName,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
'name',
|
||||||
|
'member',
|
||||||
|
'member_name',
|
||||||
|
'member_image',
|
||||||
|
'member_username',
|
||||||
|
'progress',
|
||||||
|
],
|
||||||
|
pageLength: 50,
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([searchFilter], () => {
|
||||||
|
let filterApplied = false
|
||||||
|
let filters: Filters = {
|
||||||
|
course: props.courseName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchFilter.value) {
|
||||||
|
filters.member_name = ['like', `%${searchFilter.value}%`]
|
||||||
|
filterApplied = true
|
||||||
|
}
|
||||||
|
|
||||||
|
progressList.update({
|
||||||
|
filters: filters,
|
||||||
|
})
|
||||||
|
progressList.reload(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess(data: any[]) {
|
||||||
|
memberCount.value = filterApplied ? data.length : props.enrollments || 0
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const progressColumns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: __('Member'),
|
||||||
|
key: 'member_name',
|
||||||
|
width: '60%',
|
||||||
|
icon: 'user',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Progress'),
|
||||||
|
key: 'progress',
|
||||||
|
width: '30%',
|
||||||
|
align: 'right',
|
||||||
|
icon: 'trending-up',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -97,7 +97,7 @@ import {
|
|||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { reactive, watch } from 'vue'
|
import { reactive, watch } from 'vue'
|
||||||
import { FileText, X } from 'lucide-vue-next'
|
import { FileText, X } from 'lucide-vue-next'
|
||||||
import { getFileSize, escapeHTML } from '@/utils'
|
import { getFileSize } from '@/utils'
|
||||||
|
|
||||||
const reloadProfile = defineModel('reloadProfile')
|
const reloadProfile = defineModel('reloadProfile')
|
||||||
|
|
||||||
@@ -132,7 +132,6 @@ const imageResource = createResource({
|
|||||||
const updateProfile = createResource({
|
const updateProfile = createResource({
|
||||||
url: 'frappe.client.set_value',
|
url: 'frappe.client.set_value',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
profile.bio = escapeHTML(profile.bio)
|
|
||||||
return {
|
return {
|
||||||
doctype: 'User',
|
doctype: 'User',
|
||||||
name: props.profile.data.name,
|
name: props.profile.data.name,
|
||||||
|
|||||||
192
frontend/src/components/Modals/EmailTemplateModal.vue
Normal file
192
frontend/src/components/Modals/EmailTemplateModal.vue
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title:
|
||||||
|
templateID == 'new'
|
||||||
|
? __('New Email Template')
|
||||||
|
: __('Edit Email Template'),
|
||||||
|
size: 'lg',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Save'),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: ({ close }) => {
|
||||||
|
saveTemplate(close)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<FormControl
|
||||||
|
:label="__('Name')"
|
||||||
|
v-model="template.name"
|
||||||
|
type="text"
|
||||||
|
:required="true"
|
||||||
|
:placeholder="__('Batch Enrollment Confirmation')"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
:label="__('Subject')"
|
||||||
|
v-model="template.subject"
|
||||||
|
type="text"
|
||||||
|
:required="true"
|
||||||
|
:placeholder="__('Your enrollment in {{ batch_name }} is confirmed')"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
:label="__('Use HTML')"
|
||||||
|
v-model="template.use_html"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-if="template.use_html"
|
||||||
|
:label="__('Content')"
|
||||||
|
v-model="template.response_html"
|
||||||
|
type="textarea"
|
||||||
|
:required="true"
|
||||||
|
:rows="10"
|
||||||
|
:placeholder="
|
||||||
|
__(
|
||||||
|
'<p>Dear {{ member_name }},</p>\n\n<p>You have been enrolled in our upcoming batch {{ batch_name }}.</p>\n\n<p>Thanks,</p>\n<p>Frappe Learning</p>'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<div v-else>
|
||||||
|
<div class="text-xs text-ink-gray-5 mb-2">
|
||||||
|
{{ __('Content') }}
|
||||||
|
<span class="text-ink-red-3">*</span>
|
||||||
|
</div>
|
||||||
|
<TextEditor
|
||||||
|
:content="template.response"
|
||||||
|
@change="(val) => (template.response = val)"
|
||||||
|
:editable="true"
|
||||||
|
:fixedMenu="true"
|
||||||
|
:placeholder="
|
||||||
|
__(
|
||||||
|
'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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { call, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||||
|
import { reactive, watch } from 'vue'
|
||||||
|
import { cleanError } from '@/utils'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
templateID: {
|
||||||
|
type: String,
|
||||||
|
default: 'new',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const emailTemplates = defineModel('emailTemplates')
|
||||||
|
const template = reactive({
|
||||||
|
name: '',
|
||||||
|
subject: '',
|
||||||
|
use_html: false,
|
||||||
|
response: '',
|
||||||
|
response_html: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveTemplate = (close) => {
|
||||||
|
if (props.templateID == 'new') {
|
||||||
|
createNewTemplate(close)
|
||||||
|
} else {
|
||||||
|
updateTemplate(close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createNewTemplate = (close) => {
|
||||||
|
emailTemplates.value.insert.submit(
|
||||||
|
{
|
||||||
|
__newname: template.name,
|
||||||
|
...template,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
emailTemplates.value.reload()
|
||||||
|
refreshForm(close)
|
||||||
|
toast.success(__('Email Template created successfully'))
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
refreshForm(close)
|
||||||
|
toast.error(
|
||||||
|
cleanError(err.messages[0]) || __('Error creating email template')
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTemplate = async (close) => {
|
||||||
|
if (props.templateID != template.name) {
|
||||||
|
await renameDoc()
|
||||||
|
}
|
||||||
|
setValue(close)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setValue = (close) => {
|
||||||
|
emailTemplates.value.setValue.submit(
|
||||||
|
{
|
||||||
|
...template,
|
||||||
|
name: template.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
emailTemplates.value.reload()
|
||||||
|
refreshForm(close)
|
||||||
|
toast.success(__('Email Template updated successfully'))
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
refreshForm(close)
|
||||||
|
toast.error(
|
||||||
|
cleanError(err.messages[0]) || __('Error updating email template')
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renameDoc = async () => {
|
||||||
|
await call('frappe.client.rename_doc', {
|
||||||
|
doctype: 'Email Template',
|
||||||
|
old_name: props.templateID,
|
||||||
|
new_name: template.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.templateID,
|
||||||
|
(val) => {
|
||||||
|
if (val !== 'new') {
|
||||||
|
emailTemplates.value?.data.forEach((row) => {
|
||||||
|
if (row.name === val) {
|
||||||
|
template.name = row.name
|
||||||
|
template.subject = row.subject
|
||||||
|
template.use_html = row.use_html
|
||||||
|
template.response = row.response
|
||||||
|
template.response_html = row.response_html
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ flush: 'post' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const refreshForm = (close) => {
|
||||||
|
close()
|
||||||
|
template.name = ''
|
||||||
|
template.subject = ''
|
||||||
|
template.use_html = false
|
||||||
|
template.response = ''
|
||||||
|
template.response_html = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, createResource, Select, FormControl } from 'frappe-ui'
|
import { Dialog, createResource, Select, FormControl, toast } from 'frappe-ui'
|
||||||
import { reactive, watch, inject } from 'vue'
|
import { reactive, watch, inject } from 'vue'
|
||||||
import { formatTime } from '@/utils/'
|
import { formatTime } from '@/utils/'
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
let evaluation = reactive({
|
const evaluation = reactive({
|
||||||
course: '',
|
course: '',
|
||||||
date: '',
|
date: '',
|
||||||
start_time: '',
|
start_time: '',
|
||||||
@@ -139,22 +139,13 @@ function submitEvaluation(close) {
|
|||||||
close()
|
close()
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
let message = err.messages?.[0] || err
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
let unavailabilityMessage
|
|
||||||
|
|
||||||
if (typeof message === 'string') {
|
|
||||||
unavailabilityMessage = message?.includes('unavailable')
|
|
||||||
} else {
|
|
||||||
unavailabilityMessage = false
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.warning(__('Evaluator is unavailable'))
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCourses = () => {
|
const getCourses = () => {
|
||||||
let courses = []
|
const courses = []
|
||||||
for (const course of props.courses) {
|
for (const course of props.courses) {
|
||||||
if (course.evaluator) {
|
if (course.evaluator) {
|
||||||
courses.push({
|
courses.push({
|
||||||
@@ -164,7 +155,7 @@ const getCourses = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (courses.length == 1) {
|
if (courses.length === 1) {
|
||||||
evaluation.course = courses[0].value
|
evaluation.course = courses[0].value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,8 +76,8 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Tabs :tabs="tabs" v-model="tabIndex" class="border-l w-1/2">
|
<Tabs :tabs="tabs" as="div" v-model="tabIndex" class="border-l w-1/2">
|
||||||
<template #default="{ tab }">
|
<template #tab-panel="{ tab }">
|
||||||
<div
|
<div
|
||||||
v-if="tab.label == 'Evaluation'"
|
v-if="tab.label == 'Evaluation'"
|
||||||
class="flex flex-col space-y-4 p-5"
|
class="flex flex-col space-y-4 p-5"
|
||||||
@@ -255,6 +255,9 @@ const saveEvaluation = () => {
|
|||||||
}
|
}
|
||||||
toast.success(__('Evaluation saved successfully'))
|
toast.success(__('Evaluation saved successfully'))
|
||||||
},
|
},
|
||||||
|
onError(err) {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -277,6 +280,9 @@ const certificateResource = createResource({
|
|||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
certificate.name = data
|
certificate.name = data
|
||||||
},
|
},
|
||||||
|
onError(err) {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const certificateDetails = createResource({
|
const certificateDetails = createResource({
|
||||||
@@ -310,6 +316,9 @@ const saveCertificate = () => {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(__('Certificate saved successfully'))
|
toast.success(__('Certificate saved successfully'))
|
||||||
},
|
},
|
||||||
|
onError(err) {
|
||||||
|
toast.error(__(err.messages?.[0] || err))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,5 +35,6 @@ const file = computed(() => {
|
|||||||
if (props.type == 'youtube') return '/assets/lms/frontend/Youtube.mp4'
|
if (props.type == 'youtube') return '/assets/lms/frontend/Youtube.mp4'
|
||||||
if (props.type == 'quiz') return '/assets/lms/frontend/Quiz.mp4'
|
if (props.type == 'quiz') return '/assets/lms/frontend/Quiz.mp4'
|
||||||
if (props.type == 'upload') return '/assets/lms/frontend/Upload.mp4'
|
if (props.type == 'upload') return '/assets/lms/frontend/Upload.mp4'
|
||||||
|
if (props.type == 'remove') return '/assets/lms/frontend/Remove.mp4'
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
115
frontend/src/components/Modals/FeedbackModal.vue
Normal file
115
frontend/src/components/Modals/FeedbackModal.vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
size: '4xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="p-5 min-h-[300px]">
|
||||||
|
<div class="text-lg font-semibold mb-4">
|
||||||
|
{{ __('Training Feedback') }}
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
:columns="feedbackColumns"
|
||||||
|
:rows="feedbackList"
|
||||||
|
row-key="name"
|
||||||
|
:options="{
|
||||||
|
showTooltip: false,
|
||||||
|
rowHeight: 'h-16',
|
||||||
|
selectable: false,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||||
|
></ListHeader>
|
||||||
|
<ListRows>
|
||||||
|
<ListRow
|
||||||
|
:row="row"
|
||||||
|
v-for="row in feedbackList"
|
||||||
|
class="group feedback-list"
|
||||||
|
>
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem
|
||||||
|
:item="row[column.key]"
|
||||||
|
:align="column.align"
|
||||||
|
class="text-sm"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<div v-if="column.key == 'member_name'">
|
||||||
|
<Avatar
|
||||||
|
class="flex"
|
||||||
|
:image="row['member_image']"
|
||||||
|
:label="item"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="ratingKeys.includes(column.key)">
|
||||||
|
<Rating v-model="row[column.key]" :readonly="true" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="leading-5">
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</ListRows>
|
||||||
|
</ListView>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
ListView,
|
||||||
|
Avatar,
|
||||||
|
ListHeader,
|
||||||
|
ListRows,
|
||||||
|
ListRow,
|
||||||
|
ListRowItem,
|
||||||
|
Rating,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { reactive, computed } from 'vue'
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const ratingKeys = ['content', 'instructors', 'value']
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
feedbackList: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const feedbackColumns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Member',
|
||||||
|
key: 'member_name',
|
||||||
|
width: '10rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Feedback',
|
||||||
|
key: 'feedback',
|
||||||
|
width: '15rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Content',
|
||||||
|
key: 'content',
|
||||||
|
width: '9rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Instructors',
|
||||||
|
key: 'instructors',
|
||||||
|
width: '9rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Value',
|
||||||
|
key: 'value',
|
||||||
|
width: '9rem',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
106
frontend/src/components/Modals/LiveClassAttendance.vue
Normal file
106
frontend/src/components/Modals/LiveClassAttendance.vue
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title: __('Attendance for Class - {0}').format(live_class?.title),
|
||||||
|
size: '4xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-2 gap-12 text-sm font-semibold text-ink-gray-5 pb-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{{ __('Member') }}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-20">
|
||||||
|
<div>
|
||||||
|
{{ __('Joined at') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
{{ __('Left at') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ __('Attended for') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y text-base">
|
||||||
|
<div
|
||||||
|
v-for="participant in participants.data"
|
||||||
|
@click="redirectToProfile(participant.member_username)"
|
||||||
|
class="grid grid-cols-2 items-center w-full text-base w-fit py-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Avatar
|
||||||
|
:image="participant.member_image"
|
||||||
|
:label="participant.member_name"
|
||||||
|
size="xl"
|
||||||
|
/>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ participant.member_name }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ participant.member }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-20 text-right">
|
||||||
|
<div>
|
||||||
|
{{ dayjs(participant.joined_at).format('HH:mm a') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ dayjs(participant.left_at).format('HH:mm a') }}
|
||||||
|
</div>
|
||||||
|
<div>{{ participant.duration }} {{ __('minutes') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Avatar, createListResource, Dialog, Tooltip } from 'frappe-ui'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { inject } from 'vue'
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const router = useRouter()
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
|
|
||||||
|
interface LiveClass {
|
||||||
|
name: String
|
||||||
|
title: String
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
live_class: LiveClass | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const participants = createListResource({
|
||||||
|
doctype: 'LMS Live Class Participant',
|
||||||
|
filter: {
|
||||||
|
live_class: props.live_class?.name,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
'name',
|
||||||
|
'member',
|
||||||
|
'member_name',
|
||||||
|
'member_image',
|
||||||
|
'member_username',
|
||||||
|
'joined_at',
|
||||||
|
'left_at',
|
||||||
|
'duration',
|
||||||
|
],
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const redirectToProfile = (username: string) => {
|
||||||
|
router.push({
|
||||||
|
name: 'Profile',
|
||||||
|
params: { username },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
{
|
{
|
||||||
label: 'Submit',
|
label: 'Submit',
|
||||||
variant: 'solid',
|
variant: 'solid',
|
||||||
onClick: (close) => submitLiveClass(close),
|
onClick: ({ close }) => submitLiveClass(close),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}"
|
}"
|
||||||
@@ -16,14 +16,29 @@
|
|||||||
<template #body-content>
|
<template #body-content>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div class="space-y-4">
|
||||||
<FormControl
|
<FormControl
|
||||||
type="text"
|
type="text"
|
||||||
v-model="liveClass.title"
|
v-model="liveClass.title"
|
||||||
:label="__('Title')"
|
:label="__('Title')"
|
||||||
class="mb-4"
|
|
||||||
:required="true"
|
:required="true"
|
||||||
/>
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="liveClass.date"
|
||||||
|
type="date"
|
||||||
|
: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>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
:text="
|
:text="
|
||||||
__(
|
__(
|
||||||
@@ -35,7 +50,6 @@
|
|||||||
v-model="liveClass.time"
|
v-model="liveClass.time"
|
||||||
type="time"
|
type="time"
|
||||||
:label="__('Time')"
|
:label="__('Time')"
|
||||||
class="mb-4"
|
|
||||||
:required="true"
|
:required="true"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -52,24 +66,6 @@
|
|||||||
:required="true"
|
:required="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<FormControl
|
|
||||||
v-model="liveClass.date"
|
|
||||||
type="date"
|
|
||||||
class="mb-4"
|
|
||||||
:label="__('Date')"
|
|
||||||
:required="true"
|
|
||||||
/>
|
|
||||||
<Tooltip :text="__('Duration of the live class in minutes')">
|
|
||||||
<FormControl
|
|
||||||
type="number"
|
|
||||||
v-model="liveClass.duration"
|
|
||||||
:label="__('Duration')"
|
|
||||||
class="mb-4"
|
|
||||||
:required="true"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="liveClass.auto_recording"
|
v-model="liveClass.auto_recording"
|
||||||
type="select"
|
type="select"
|
||||||
@@ -107,7 +103,11 @@ const dayjs = inject('$dayjs')
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
type: String,
|
type: String,
|
||||||
default: null,
|
required: true,
|
||||||
|
},
|
||||||
|
zoomAccount: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -159,6 +159,7 @@ const createLiveClass = createResource({
|
|||||||
return {
|
return {
|
||||||
doctype: 'LMS Live Class',
|
doctype: 'LMS Live Class',
|
||||||
batch_name: values.batch,
|
batch_name: values.batch,
|
||||||
|
zoom_account: props.zoomAccount,
|
||||||
...values,
|
...values,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -167,6 +168,20 @@ const createLiveClass = createResource({
|
|||||||
const submitLiveClass = (close) => {
|
const submitLiveClass = (close) => {
|
||||||
return createLiveClass.submit(liveClass, {
|
return createLiveClass.submit(liveClass, {
|
||||||
validate() {
|
validate() {
|
||||||
|
validateFormFields()
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
liveClasses.value.reload()
|
||||||
|
refreshForm()
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
toast.error(err.messages?.[0] || err)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateFormFields = () => {
|
||||||
if (!liveClass.title) {
|
if (!liveClass.title) {
|
||||||
return __('Please enter a title.')
|
return __('Please enter a title.')
|
||||||
}
|
}
|
||||||
@@ -197,15 +212,6 @@ const submitLiveClass = (close) => {
|
|||||||
if (!liveClass.duration) {
|
if (!liveClass.duration) {
|
||||||
return __('Please select a duration.')
|
return __('Please select a duration.')
|
||||||
}
|
}
|
||||||
},
|
|
||||||
onSuccess() {
|
|
||||||
liveClasses.value.reload()
|
|
||||||
close()
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
toast.error(err.messages?.[0] || err)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const valideTime = () => {
|
const valideTime = () => {
|
||||||
@@ -221,4 +227,14 @@ const valideTime = () => {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refreshForm = () => {
|
||||||
|
liveClass.title = ''
|
||||||
|
liveClass.description = ''
|
||||||
|
liveClass.date = ''
|
||||||
|
liveClass.time = ''
|
||||||
|
liveClass.duration = ''
|
||||||
|
liveClass.timezone = getUserTimezone()
|
||||||
|
liveClass.auto_recording = 'No Recording'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<Dialog
|
<Dialog
|
||||||
v-model="show"
|
v-model="show"
|
||||||
:options="{
|
:options="{
|
||||||
size: '3xl',
|
size: '5xl',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
class="!p-0"
|
class="!p-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!chooseFromExisting || editMode" class="space-y-2">
|
<div v-if="!chooseFromExisting || editMode">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-ink-gray-5 mb-1">
|
<label class="block text-xs text-ink-gray-5 mb-1">
|
||||||
{{ __('Question') }}
|
{{ __('Question') }}
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-8 mt-4">
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="question.marks"
|
v-model="question.marks"
|
||||||
:label="__('Marks')"
|
:label="__('Marks')"
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="question.type == 'Choices'"
|
v-if="question.type == 'Choices'"
|
||||||
class="text-base font-semibold text-ink-gray-9 mb-5 mt-5"
|
class="text-base font-semibold text-ink-gray-9 mb-5 mt-10"
|
||||||
>
|
>
|
||||||
{{ __('Options') }}
|
{{ __('Options') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -61,7 +61,10 @@
|
|||||||
>
|
>
|
||||||
{{ __('Possibilities') }}
|
{{ __('Possibilities') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="question.type == 'Choices'" class="grid grid-cols-2 gap-4">
|
<div
|
||||||
|
v-if="question.type == 'Choices'"
|
||||||
|
class="grid grid-cols-2 gap-x-8 gap-y-4"
|
||||||
|
>
|
||||||
<div v-for="n in 4" class="space-y-4 py-2">
|
<div v-for="n in 4" class="space-y-4 py-2">
|
||||||
<FormControl
|
<FormControl
|
||||||
:label="__('Option') + ' ' + n"
|
:label="__('Option') + ' ' + n"
|
||||||
@@ -81,7 +84,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="question.type == 'User Input'"
|
v-else-if="question.type == 'User Input'"
|
||||||
class="grid grid-cols-2 gap-4 py-2"
|
class="grid grid-cols-2 gap-x-8 gap-y-4 py-2"
|
||||||
>
|
>
|
||||||
<div v-for="n in 4">
|
<div v-for="n in 4">
|
||||||
<FormControl
|
<FormControl
|
||||||
@@ -106,7 +109,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-end space-x-2 mt-5">
|
<div class="flex items-center justify-end space-x-2 mt-5">
|
||||||
<Button variant="solid" @click="submitQuestion()">
|
<Button variant="solid" @click="submitQuestion()">
|
||||||
{{ __('Submit') }}
|
{{ __('Save') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,7 +220,7 @@ const questionRow = createResource({
|
|||||||
return {
|
return {
|
||||||
doc: {
|
doc: {
|
||||||
doctype: 'LMS Quiz Question',
|
doctype: 'LMS Quiz Question',
|
||||||
parent: quiz.value.data.name,
|
parent: quiz.value.doc.name,
|
||||||
parentfield: 'questions',
|
parentfield: 'questions',
|
||||||
parenttype: 'LMS Quiz',
|
parenttype: 'LMS Quiz',
|
||||||
...values,
|
...values,
|
||||||
|
|||||||
225
frontend/src/components/Modals/QuizInVideo.vue
Normal file
225
frontend/src/components/Modals/QuizInVideo.vue
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title: __('Add quiz to this video'),
|
||||||
|
size: '2xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="text-base">
|
||||||
|
<div class="flex items-end gap-4">
|
||||||
|
<FormControl
|
||||||
|
:label="__('Time in Video')"
|
||||||
|
v-model="quiz.time"
|
||||||
|
type="text"
|
||||||
|
placeholder="2:15"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
v-model="quiz.quiz"
|
||||||
|
:label="__('Quiz')"
|
||||||
|
doctype="LMS Quiz"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Button @click="addQuiz()" variant="solid">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="w-4 h-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Add') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 mb-5">
|
||||||
|
<div class="font-medium mb-4">
|
||||||
|
{{ __('Quizzes in this video') }}
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
v-if="allQuizzes.length"
|
||||||
|
:columns="columns"
|
||||||
|
:rows="allQuizzes"
|
||||||
|
row-key="quiz"
|
||||||
|
: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 columns">
|
||||||
|
<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 allQuizzes">
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem
|
||||||
|
:item="row[column.key as keyof Quiz]"
|
||||||
|
:align="column.align"
|
||||||
|
>
|
||||||
|
<div v-if="column.key == 'time'" class="leading-5 text-sm">
|
||||||
|
{{ formatTimestamp(row[column.key as keyof Quiz]) }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="leading-5 text-sm">
|
||||||
|
{{ row[column.key as keyof Quiz] }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</ListRows>
|
||||||
|
|
||||||
|
<ListSelectBanner>
|
||||||
|
<template #actions="{ unselectAll, selections }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="removeQuiz(selections, unselectAll)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListSelectBanner>
|
||||||
|
</ListView>
|
||||||
|
|
||||||
|
<div v-else class="text-ink-gray-5 italic text-xs">
|
||||||
|
{{ __('No quizzes added yet.') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
ListView,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListRows,
|
||||||
|
ListRow,
|
||||||
|
ListRowItem,
|
||||||
|
ListSelectBanner,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { formatTimestamp } from '@/utils'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
|
type Quiz = {
|
||||||
|
time: string
|
||||||
|
quiz: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const allQuizzes = ref<Quiz[]>([])
|
||||||
|
const quiz = reactive<Quiz>({
|
||||||
|
time: '',
|
||||||
|
quiz: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
quizzes: {
|
||||||
|
type: Array as () => Quiz[],
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
saveQuizzes: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
duration: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const addQuiz = () => {
|
||||||
|
quiz.time = `${getTimeInSeconds()}`
|
||||||
|
if (!isTimeValid() || !isFormComplete()) return
|
||||||
|
|
||||||
|
allQuizzes.value.push({
|
||||||
|
time: quiz.time,
|
||||||
|
quiz: quiz.quiz,
|
||||||
|
})
|
||||||
|
|
||||||
|
props.saveQuizzes(allQuizzes.value)
|
||||||
|
|
||||||
|
quiz.time = ''
|
||||||
|
quiz.quiz = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTimeInSeconds = () => {
|
||||||
|
if (quiz.time && !quiz.time.includes(':')) {
|
||||||
|
quiz.time = `${quiz.time}:00`
|
||||||
|
}
|
||||||
|
const timeParts = quiz.time.split(':')
|
||||||
|
const timeInSeconds = parseInt(timeParts[0]) * 60 + parseInt(timeParts[1])
|
||||||
|
|
||||||
|
return timeInSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTimeValid = () => {
|
||||||
|
if (parseInt(quiz.time) > props.duration) {
|
||||||
|
toast.error(__('Time in video exceeds the total duration of the video.'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFormComplete = () => {
|
||||||
|
if (!quiz.time) {
|
||||||
|
toast.error(__('Please enter a valid timestamp'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!quiz.quiz) {
|
||||||
|
toast.error(__('Please select a quiz'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeQuiz = (selections: string, unselectAll: () => void) => {
|
||||||
|
Array.from(selections).forEach((selection) => {
|
||||||
|
const index = allQuizzes.value.findIndex((q) => q.quiz === selection)
|
||||||
|
if (index !== -1) {
|
||||||
|
allQuizzes.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
unselectAll()
|
||||||
|
})
|
||||||
|
props.saveQuizzes(allQuizzes.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.quizzes,
|
||||||
|
(newQuizzes) => {
|
||||||
|
allQuizzes.value = newQuizzes
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'quiz',
|
||||||
|
label: __('Quiz'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'time',
|
||||||
|
label: __('Time in Video (minutes)'),
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -15,26 +15,20 @@
|
|||||||
>
|
>
|
||||||
<template #body-content>
|
<template #body-content>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div>
|
<Rating v-model="review.rating" :label="__('Rating')" />
|
||||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
<FormControl
|
||||||
{{ __('Rating') }}
|
:label="__('Review')"
|
||||||
</div>
|
type="textarea"
|
||||||
<Rating v-model="review.rating" />
|
v-model="review.review"
|
||||||
</div>
|
:rows="5"
|
||||||
<div>
|
/>
|
||||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
|
||||||
{{ __('Review') }}
|
|
||||||
</div>
|
|
||||||
<Textarea type="text" size="md" rows="5" v-model="review.review" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, Textarea, createResource, toast } from 'frappe-ui'
|
import { Dialog, FormControl, createResource, toast, Rating } from 'frappe-ui'
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import Rating from '@/components/Controls/Rating.vue'
|
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const reviews = defineModel('reloadReviews')
|
const reviews = defineModel('reloadReviews')
|
||||||
|
|||||||
250
frontend/src/components/Modals/VideoStatistics.vue
Normal file
250
frontend/src/components/Modals/VideoStatistics.vue
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
size: '4xl',
|
||||||
|
title: __('Video Statistics for {0}').format(lessonTitle),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="text-base">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<TabButtons
|
||||||
|
v-if="tabs.length > 1"
|
||||||
|
:buttons="tabs"
|
||||||
|
v-model="currentTab"
|
||||||
|
class="w-fit"
|
||||||
|
/>
|
||||||
|
<!-- <FormControl
|
||||||
|
v-model="searchText"
|
||||||
|
:placeholder="__('Search by Member')"
|
||||||
|
class="mt-2 mr-5 w-[25%]"
|
||||||
|
/> -->
|
||||||
|
</div>
|
||||||
|
<div v-if="currentTab" class="mt-4">
|
||||||
|
<div class="grid grid-cols-[55%,40%] gap-5">
|
||||||
|
<div class="space-y-5 border rounded-md p-2 pt-4">
|
||||||
|
<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') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="row in currentTabData"
|
||||||
|
class="hover:bg-surface-gray-1 cursor-pointer rounded-md py-1 px-2"
|
||||||
|
>
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'Profile',
|
||||||
|
params: { username: row.member_username },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-[70%,30%] items-center">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Avatar
|
||||||
|
:image="row.member_image"
|
||||||
|
:label="row.member_name"
|
||||||
|
size="xl"
|
||||||
|
/>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ row.member_name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-ink-gray-6">
|
||||||
|
{{ row.member }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center text-sm">
|
||||||
|
{{ convertToMinutes(row.watch_time) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<NumberChart
|
||||||
|
class="border rounded-md"
|
||||||
|
:config="{
|
||||||
|
title: __('Average Watch Time (minutes)'),
|
||||||
|
value: averageWatchTime,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<div v-if="isPlyrSource">
|
||||||
|
<div class="video-player" :src="currentTab"></div>
|
||||||
|
</div>
|
||||||
|
<VideoBlock v-else :file="currentTab" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm text-ink-gray-5">
|
||||||
|
{{ __('No statistics available for this video.') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
createListResource,
|
||||||
|
Dialog,
|
||||||
|
FormControl,
|
||||||
|
NumberChart,
|
||||||
|
TabButtons,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { enablePlyr, convertToMinutes } from '@/utils'
|
||||||
|
import VideoBlock from '@/components/VideoBlock.vue'
|
||||||
|
|
||||||
|
const show = defineModel<boolean | undefined>()
|
||||||
|
const currentTab = ref<string>('')
|
||||||
|
const searchText = ref<string>('')
|
||||||
|
type Filters = {
|
||||||
|
lesson: string | undefined
|
||||||
|
member_name?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
lessonName?: string
|
||||||
|
lessonTitle?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const statistics = createListResource({
|
||||||
|
doctype: 'LMS Video Watch Duration',
|
||||||
|
filters: {
|
||||||
|
lesson: props.lessonName,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
'name',
|
||||||
|
'member',
|
||||||
|
'member_name',
|
||||||
|
'member_image',
|
||||||
|
'member_username',
|
||||||
|
'source',
|
||||||
|
'watch_time',
|
||||||
|
],
|
||||||
|
cache: ['videoStatistics', props.lessonName],
|
||||||
|
onSuccess() {
|
||||||
|
currentTab.value = Object.keys(statisticsData.value)[0]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.lessonName,
|
||||||
|
() => {
|
||||||
|
if (props.lessonName) {
|
||||||
|
statistics.filters.lesson = props.lessonName
|
||||||
|
statistics.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(searchText, () => {
|
||||||
|
let filterApplied = false
|
||||||
|
let filters: Filters = {
|
||||||
|
lesson: props.lessonName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchText.value) {
|
||||||
|
filters.member_name = ['like', `%${searchText.value}%`]
|
||||||
|
filterApplied = true
|
||||||
|
}
|
||||||
|
|
||||||
|
statistics.update({
|
||||||
|
filters: filters,
|
||||||
|
})
|
||||||
|
|
||||||
|
statistics.reload({})
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(show, () => {
|
||||||
|
if (show.value) {
|
||||||
|
enablePlyr()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const statisticsData = computed(() => {
|
||||||
|
const grouped = <Record<string, any[]>>{}
|
||||||
|
statistics.data.forEach((item: { source: string }) => {
|
||||||
|
if (!grouped[item.source]) {
|
||||||
|
grouped[item.source] = []
|
||||||
|
}
|
||||||
|
grouped[item.source].push(item)
|
||||||
|
})
|
||||||
|
return grouped
|
||||||
|
})
|
||||||
|
|
||||||
|
const averageWatchTime = computed(() => {
|
||||||
|
let totalWatchTime = 0
|
||||||
|
|
||||||
|
currentTabData.value.forEach((item: { watch_time: string }) => {
|
||||||
|
totalWatchTime += parseFloat(item.watch_time)
|
||||||
|
})
|
||||||
|
|
||||||
|
return convertToMinutes(totalWatchTime / currentTabData.value.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentTabData = computed(() => {
|
||||||
|
return statisticsData.value[currentTab.value] || []
|
||||||
|
})
|
||||||
|
|
||||||
|
const isPlyrSource = computed(() => {
|
||||||
|
return (
|
||||||
|
currentTab.value.includes('youtube') || currentTab.value.includes('vimeo')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const provider = computed(() => {
|
||||||
|
if (currentTab.value.includes('youtube')) {
|
||||||
|
return 'youtube'
|
||||||
|
} else if (currentTab.value.includes('vimeo')) {
|
||||||
|
return 'vimeo'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const embedURL = computed(() => {
|
||||||
|
if (isPlyrSource.value) {
|
||||||
|
return currentTab.value.replace('watch?v=', 'embed/')
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const tabs = computed(() => {
|
||||||
|
return Object.keys(statisticsData.value).map((source, index) => ({
|
||||||
|
label: __(`Video ${index + 1}`),
|
||||||
|
value: source,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.plyr__volume input[type='range'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plyr__control--overlaid {
|
||||||
|
background: radial-gradient(
|
||||||
|
circle,
|
||||||
|
rgba(0, 0, 0, 0.4) 0%,
|
||||||
|
rgba(0, 0, 0, 0.5) 50%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plyr__control:hover {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plyr--video {
|
||||||
|
border: 1px solid theme('colors.gray.200');
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--plyr-range-fill-background: white;
|
||||||
|
--plyr-video-control-background-hover: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
213
frontend/src/components/Modals/ZoomAccountModal.vue
Normal file
213
frontend/src/components/Modals/ZoomAccountModal.vue
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title:
|
||||||
|
accountID === 'new' ? __('New Zoom Account') : __('Edit Zoom Account'),
|
||||||
|
size: 'xl',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Save'),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: ({ close }) => {
|
||||||
|
saveAccount(close)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="mb-4">
|
||||||
|
<FormControl
|
||||||
|
v-model="account.enabled"
|
||||||
|
:label="__('Enabled')"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-5">
|
||||||
|
<FormControl
|
||||||
|
v-model="account.name"
|
||||||
|
:label="__('Account Name')"
|
||||||
|
type="text"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="account.client_id"
|
||||||
|
:label="__('Client ID')"
|
||||||
|
type="text"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
v-model="account.member"
|
||||||
|
:label="__('Member')"
|
||||||
|
doctype="Course Evaluator"
|
||||||
|
:onCreate="(value: string, close: () => void) => openSettings('Members', close)"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="account.client_secret"
|
||||||
|
:label="__('Client Secret')"
|
||||||
|
type="password"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="account.account_id"
|
||||||
|
:label="__('Account ID')"
|
||||||
|
type="text"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { call, Dialog, FormControl, 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'
|
||||||
|
|
||||||
|
interface ZoomAccount {
|
||||||
|
name: string
|
||||||
|
account_name: string
|
||||||
|
enabled: boolean
|
||||||
|
member: string
|
||||||
|
account_id: string
|
||||||
|
client_id: string
|
||||||
|
client_secret: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZoomAccounts {
|
||||||
|
data: ZoomAccount[]
|
||||||
|
reload: () => void
|
||||||
|
insert: {
|
||||||
|
submit: (
|
||||||
|
data: ZoomAccount,
|
||||||
|
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
setValue: {
|
||||||
|
submit: (
|
||||||
|
data: ZoomAccount,
|
||||||
|
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const show = defineModel('show')
|
||||||
|
const user = inject<User | null>('$user')
|
||||||
|
const zoomAccounts = defineModel<ZoomAccounts>('zoomAccounts')
|
||||||
|
|
||||||
|
const account = reactive({
|
||||||
|
name: '',
|
||||||
|
enabled: false,
|
||||||
|
member: user?.data?.name || '',
|
||||||
|
account_id: '',
|
||||||
|
client_id: '',
|
||||||
|
client_secret: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
accountID: {
|
||||||
|
type: String,
|
||||||
|
default: 'new',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.accountID,
|
||||||
|
(val) => {
|
||||||
|
if (val != 'new') {
|
||||||
|
zoomAccounts.value?.data.forEach((acc) => {
|
||||||
|
if (acc.name === val) {
|
||||||
|
account.name = acc.name
|
||||||
|
account.enabled = acc.enabled || false
|
||||||
|
account.member = acc.member
|
||||||
|
account.account_id = acc.account_id
|
||||||
|
account.client_id = acc.client_id
|
||||||
|
account.client_secret = acc.client_secret
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(show, (val) => {
|
||||||
|
if (!val) {
|
||||||
|
account.name = ''
|
||||||
|
account.enabled = false
|
||||||
|
account.member = user?.data?.name || ''
|
||||||
|
account.account_id = ''
|
||||||
|
account.client_id = ''
|
||||||
|
account.client_secret = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveAccount = (close: () => void) => {
|
||||||
|
if (props.accountID == 'new') {
|
||||||
|
createAccount(close)
|
||||||
|
} else {
|
||||||
|
updateAccount(close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createAccount = (close: () => void) => {
|
||||||
|
zoomAccounts.value?.insert.submit(
|
||||||
|
{
|
||||||
|
account_name: account.name,
|
||||||
|
...account,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
zoomAccounts.value?.reload()
|
||||||
|
close()
|
||||||
|
toast.success(__('Zoom Account created successfully'))
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
close()
|
||||||
|
toast.error(
|
||||||
|
cleanError(err.messages[0]) || __('Error creating Zoom 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 Zoom Settings',
|
||||||
|
old_name: props.accountID,
|
||||||
|
new_name: account.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const setValue = (close: () => void) => {
|
||||||
|
zoomAccounts.value?.setValue.submit(
|
||||||
|
{
|
||||||
|
...account,
|
||||||
|
name: account.name,
|
||||||
|
account_name: props.accountID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
zoomAccounts.value?.reload()
|
||||||
|
close()
|
||||||
|
toast.success(__('Zoom Account updated successfully'))
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
close()
|
||||||
|
toast.error(
|
||||||
|
cleanError(err.messages[0]) || __('Error updating Zoom Account')
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="quiz.data">
|
<div v-if="quiz.data">
|
||||||
<div
|
<div
|
||||||
class="bg-surface-blue-2 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-ink-blue-3"
|
class="bg-surface-blue-2 space-y-2 py-2 px-3 mb-4 rounded-md text-sm text-ink-blue-2 leading-5"
|
||||||
>
|
>
|
||||||
|
<div v-if="inVideo">
|
||||||
|
{{ __('You will have to complete the quiz to continue the video') }}
|
||||||
|
</div>
|
||||||
<div class="leading-5">
|
<div class="leading-5">
|
||||||
{{
|
{{
|
||||||
__('This quiz consists of {0} questions.').format(questions.length)
|
__('This quiz consists of {0} questions.').format(questions.length)
|
||||||
@@ -38,6 +41,16 @@
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</div>
|
</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'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="quiz.data.duration" class="flex flex-col space-x-1 my-4">
|
<div v-if="quiz.data.duration" class="flex flex-col space-x-1 my-4">
|
||||||
@@ -55,19 +68,30 @@
|
|||||||
<div class="font-semibold text-lg text-ink-gray-9">
|
<div class="font-semibold text-lg text-ink-gray-9">
|
||||||
{{ quiz.data.title }}
|
{{ quiz.data.title }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center justify-center space-x-2 mt-4">
|
||||||
<Button
|
<Button
|
||||||
v-if="
|
v-if="
|
||||||
!quiz.data.max_attempts ||
|
!quiz.data.max_attempts ||
|
||||||
attempts.data?.length < quiz.data.max_attempts
|
attempts.data?.length < quiz.data.max_attempts
|
||||||
"
|
"
|
||||||
|
variant="solid"
|
||||||
@click="startQuiz"
|
@click="startQuiz"
|
||||||
class="mt-2"
|
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{{ __('Start') }}
|
{{ inVideo ? __('Start the Quiz') : __('Start') }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<div v-else class="leading-5 text-ink-gray-7">
|
<Button v-if="inVideo" @click="props.backToVideo()">
|
||||||
|
{{ __('Resume Video') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
quiz.data.max_attempts &&
|
||||||
|
attempts.data?.length >= quiz.data.max_attempts
|
||||||
|
"
|
||||||
|
class="leading-5 text-ink-gray-7"
|
||||||
|
>
|
||||||
{{
|
{{
|
||||||
__(
|
__(
|
||||||
'You have already exceeded the maximum number of attempts allowed for this quiz.'
|
'You have already exceeded the maximum number of attempts allowed for this quiz.'
|
||||||
@@ -247,6 +271,7 @@
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="space-x-2">
|
||||||
<Button
|
<Button
|
||||||
@click="resetQuiz()"
|
@click="resetQuiz()"
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
@@ -259,6 +284,10 @@
|
|||||||
{{ __('Try Again') }}
|
{{ __('Try Again') }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button v-if="inVideo" @click="props.backToVideo()">
|
||||||
|
{{ __('Resume Video') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
@@ -308,13 +337,20 @@ let questions = reactive([])
|
|||||||
const possibleAnswer = ref(null)
|
const possibleAnswer = ref(null)
|
||||||
const timer = ref(0)
|
const timer = ref(0)
|
||||||
let timerInterval = null
|
let timerInterval = null
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
quizName: {
|
quizName: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
inVideo: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
backToVideo: {
|
||||||
|
type: Function,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const quiz = createResource({
|
const quiz = createResource({
|
||||||
@@ -611,11 +647,17 @@ const getInstructions = (question) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const markLessonProgress = () => {
|
const markLessonProgress = () => {
|
||||||
if (router.currentRoute.value.name == 'Lesson') {
|
let pathname = window.location.pathname.split('/')
|
||||||
|
if (!pathname.includes('courses'))
|
||||||
|
pathname = window.parent.location.pathname.split('/')
|
||||||
|
if (pathname[2] != 'courses') return
|
||||||
|
let lessonIndex = pathname.pop().split('-')
|
||||||
|
|
||||||
|
if (lessonIndex.length == 2) {
|
||||||
call('lms.lms.api.mark_lesson_progress', {
|
call('lms.lms.api.mark_lesson_progress', {
|
||||||
course: router.currentRoute.value.params.courseName,
|
course: pathname[3],
|
||||||
chapter_number: router.currentRoute.value.params.chapterNumber,
|
chapter_number: lessonIndex[0],
|
||||||
lesson_number: router.currentRoute.value.params.lessonNumber,
|
lesson_number: lessonIndex[1],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
52
frontend/src/components/RelatedCourses.vue
Normal file
52
frontend/src/components/RelatedCourses.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="relatedCourses.data?.length" class="mt-10">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div class="text-2xl font-semibold text-ink-gray-9">
|
||||||
|
{{ __('Related Courses') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-4"
|
||||||
|
>
|
||||||
|
<router-link
|
||||||
|
v-for="course in relatedCourses.data"
|
||||||
|
:key="course.name"
|
||||||
|
:to="{ name: 'CourseDetail', params: { courseName: course.name } }"
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
<CourseCard :course="course" />
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { createResource } from 'frappe-ui'
|
||||||
|
import { watch } from 'vue'
|
||||||
|
import CourseCard from '@/components/CourseCard.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
courseName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const relatedCourses = createResource({
|
||||||
|
url: 'lms.lms.utils.get_related_courses',
|
||||||
|
cache: ['related_courses', props.courseName],
|
||||||
|
makeParams() {
|
||||||
|
return {
|
||||||
|
course: props.courseName,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.courseName,
|
||||||
|
() => {
|
||||||
|
relatedCourses.reload()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
142
frontend/src/components/Settings/BadgeAssignmentForm.vue
Normal file
142
frontend/src/components/Settings/BadgeAssignmentForm.vue
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title:
|
||||||
|
props.badgeAssignmentID === 'new'
|
||||||
|
? __('Assign a Badge')
|
||||||
|
: __('Edit Badge Assignment'),
|
||||||
|
size: 'sm',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Save'),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: ({ close }) => {
|
||||||
|
saveBadgeAssignment(close)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<Link
|
||||||
|
doctype="User"
|
||||||
|
v-model="badgeAssignment.member"
|
||||||
|
:label="__('Member')"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
doctype="LMS Badge"
|
||||||
|
v-model="badgeAssignment.badge"
|
||||||
|
:label="__('Badge')"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-ink-gray-5 mb-1">
|
||||||
|
{{ __('Issued On') }}
|
||||||
|
<span class="text-ink-red-3">*</span>
|
||||||
|
</label>
|
||||||
|
<DatePicker
|
||||||
|
v-model="badgeAssignment.issued_on"
|
||||||
|
:placeholder="__('Select Date')"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Dialog, DatePicker, toast } from 'frappe-ui'
|
||||||
|
import type {
|
||||||
|
BadgeAssignments,
|
||||||
|
BadgeAssignment,
|
||||||
|
} from '@/components/Settings/types'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { cleanError } from '@/utils'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
|
const show = defineModel<boolean>({ required: true, default: false })
|
||||||
|
const defaultBadgeAssignment = {
|
||||||
|
name: '',
|
||||||
|
badge: '',
|
||||||
|
member: '',
|
||||||
|
issued_on: '',
|
||||||
|
member_name: '',
|
||||||
|
member_username: '',
|
||||||
|
member_image: '',
|
||||||
|
}
|
||||||
|
const badgeAssignments = defineModel<BadgeAssignments>('badgeAssignments')
|
||||||
|
const badgeAssignment = ref<BadgeAssignment>(defaultBadgeAssignment)
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
badgeAssignmentID: string
|
||||||
|
badge: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.badgeAssignmentID,
|
||||||
|
(newID) => {
|
||||||
|
if (newID === 'new') {
|
||||||
|
badgeAssignment.value = {
|
||||||
|
...defaultBadgeAssignment,
|
||||||
|
badge: props.badge || '',
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const assignment = badgeAssignments.value?.data?.find(
|
||||||
|
(assignment) => assignment.name === newID
|
||||||
|
)
|
||||||
|
if (assignment) {
|
||||||
|
badgeAssignment.value = { ...assignment }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const saveBadgeAssignment = (close: () => void) => {
|
||||||
|
if (props.badgeAssignmentID === 'new') {
|
||||||
|
createBadgeAssignment(close)
|
||||||
|
} else {
|
||||||
|
updateBadgeAssignment(close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateBadgeAssignment = async (close: () => void) => {
|
||||||
|
badgeAssignments.value?.setValue.submit(
|
||||||
|
{
|
||||||
|
...badgeAssignment.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(__('Badge assignment updated successfully'))
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(
|
||||||
|
__('Failed to update badge assignment: ') + cleanError(error)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createBadgeAssignment = (close: () => void) => {
|
||||||
|
badgeAssignments.value?.insert.submit(
|
||||||
|
{
|
||||||
|
...badgeAssignment.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(__('Badge assignment created successfully'))
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(
|
||||||
|
__('Failed to create badge assignment: ') + cleanError(error)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
192
frontend/src/components/Settings/BadgeAssignments.vue
Normal file
192
frontend/src/components/Settings/BadgeAssignments.vue
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-base">
|
||||||
|
<div class="flex items-center justify-between space-x-2 mb-5">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<ChevronLeft
|
||||||
|
class="size-5 stroke-1.5 text-ink-gray-5 cursor-pointer"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
show = false
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<div class="text-xl font-semibold text-ink-gray-9">
|
||||||
|
{{ props.badgeName }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button @click="openForm('new')">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('New') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div v-if="assignments.data?.length">
|
||||||
|
<ListView
|
||||||
|
:rows="assignments.data"
|
||||||
|
:columns="columns"
|
||||||
|
rowKey="name"
|
||||||
|
:options="{
|
||||||
|
showTooltip: false,
|
||||||
|
onRowClick: (row: BadgeAssignment) => {
|
||||||
|
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 assignments.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 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="deleteBadgeAssignment(selections, unselectAll)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListSelectBanner>
|
||||||
|
</ListView>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-col items-center justify-center mt-44">
|
||||||
|
<GraduationCap class="size-10 mx-auto stroke-1 text-ink-gray-5" />
|
||||||
|
<div class="text-lg font-semibold text-ink-gray-7 mb-2.5">
|
||||||
|
{{ __('No Assignments') }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="leading-5 text-base w-2/5 text-base text-center text-ink-gray-7"
|
||||||
|
>
|
||||||
|
{{ __('This badge has not been assigned to any students yet') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BadgeAssignmentForm
|
||||||
|
v-model="showForm"
|
||||||
|
:badgeAssignmentID="currentAssignmentID"
|
||||||
|
:badge="props.badgeName"
|
||||||
|
v-model:badgeAssignments="assignments"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
createListResource,
|
||||||
|
FeatherIcon,
|
||||||
|
ListView,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListRows,
|
||||||
|
ListRow,
|
||||||
|
ListRowItem,
|
||||||
|
ListSelectBanner,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { ChevronLeft, GraduationCap, Plus, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { computed, inject, ref } from 'vue'
|
||||||
|
import type { BadgeAssignment } from '@/components/Settings/types'
|
||||||
|
import BadgeAssignmentForm from '@/components/Settings/BadgeAssignmentForm.vue'
|
||||||
|
|
||||||
|
const show = defineModel<boolean>()
|
||||||
|
const dayjs = inject('$dayjs') as any
|
||||||
|
const showForm = ref(false)
|
||||||
|
const currentAssignmentID = ref<string>('')
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
badgeName: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const assignments = createListResource({
|
||||||
|
doctype: 'LMS Badge Assignment',
|
||||||
|
fields: [
|
||||||
|
'name',
|
||||||
|
'member',
|
||||||
|
'member_name',
|
||||||
|
'member_username',
|
||||||
|
'member_image',
|
||||||
|
'issued_on',
|
||||||
|
'badge',
|
||||||
|
],
|
||||||
|
filters: {
|
||||||
|
badge: props.badgeName,
|
||||||
|
},
|
||||||
|
order_by: 'issued_on desc',
|
||||||
|
transform(data: BadgeAssignment[]) {
|
||||||
|
return data.map((item: BadgeAssignment) => {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
issued_on: item.issued_on
|
||||||
|
? dayjs(item.issued_on).format('DD MMM YYYY')
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const openForm = (assignmentID: string) => {
|
||||||
|
currentAssignmentID.value = assignmentID
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteBadgeAssignment = (
|
||||||
|
selections: Set<string>,
|
||||||
|
unselectAll: () => void
|
||||||
|
) => {
|
||||||
|
Array.from(selections).forEach(async (assignment: string) => {
|
||||||
|
await assignments.delete.submit(assignment)
|
||||||
|
})
|
||||||
|
unselectAll()
|
||||||
|
toast.success(__('Badge assignments deleted successfully'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: __('Member'),
|
||||||
|
key: 'member_name',
|
||||||
|
icon: 'user',
|
||||||
|
width: '60%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Issued On'),
|
||||||
|
key: 'issued_on',
|
||||||
|
icon: 'calendar',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
219
frontend/src/components/Settings/BadgeForm.vue
Normal file
219
frontend/src/components/Settings/BadgeForm.vue
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title: badge ? __('Edit Badge') : __('Create a new Badge'),
|
||||||
|
size: '3xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="badge.title"
|
||||||
|
:label="__('Title')"
|
||||||
|
type="text"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<Autocomplete
|
||||||
|
@update:modelValue="(opt: any) => (badge.reference_doctype = opt.value)"
|
||||||
|
:modelValue="badge.reference_doctype"
|
||||||
|
:options="referenceDoctypeOptions"
|
||||||
|
:required="true"
|
||||||
|
:label="__('Assign For')"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="badge.description"
|
||||||
|
:label="__('Description')"
|
||||||
|
:required="true"
|
||||||
|
type="textarea"
|
||||||
|
/>
|
||||||
|
<Uploader
|
||||||
|
v-model="badge.image"
|
||||||
|
label="Badge Image"
|
||||||
|
description="An image that represents the badge."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<FormControl
|
||||||
|
v-model="badge.grant_only_once"
|
||||||
|
:label="__('Grant Only Once')"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="badge.event"
|
||||||
|
:label="__('Event')"
|
||||||
|
type="select"
|
||||||
|
:options="eventOptions"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="badge.user_field"
|
||||||
|
:label="__('Assign To')"
|
||||||
|
type="select"
|
||||||
|
:options="userFieldOptions"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<CodeEditor
|
||||||
|
v-model="badge.condition"
|
||||||
|
:label="__('Condition')"
|
||||||
|
type="JavaScript"
|
||||||
|
:required="true"
|
||||||
|
:showBorder="true"
|
||||||
|
height="82px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ close }">
|
||||||
|
<div class="pb-5 float-right">
|
||||||
|
<Button variant="solid" @click="saveBadge(close)">
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Button, call, Dialog, FormControl, toast } from 'frappe-ui'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { cleanError } from '@/utils'
|
||||||
|
import type { Badges, Badge } from '@/components/Settings/types'
|
||||||
|
import Autocomplete from '@/components/Controls/Autocomplete.vue'
|
||||||
|
import CodeEditor from '@/components/Controls/CodeEditor.vue'
|
||||||
|
import Uploader from '@/components/Controls/Uploader.vue'
|
||||||
|
|
||||||
|
const defaultBadge = {
|
||||||
|
name: '',
|
||||||
|
title: '',
|
||||||
|
enabled: true,
|
||||||
|
description: '',
|
||||||
|
image: '',
|
||||||
|
grant_only_once: false,
|
||||||
|
event: 'New',
|
||||||
|
reference_doctype: '',
|
||||||
|
condition: '',
|
||||||
|
user_field: 'member',
|
||||||
|
field_to_check: '',
|
||||||
|
}
|
||||||
|
const show = defineModel<boolean>({ required: true, default: false })
|
||||||
|
const badges = defineModel<Badges>('badges')
|
||||||
|
const badge = ref<Badge>(defaultBadge)
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
badgeName: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.badgeName,
|
||||||
|
(val) => {
|
||||||
|
if (val != 'new') {
|
||||||
|
badges.value?.data.forEach((bdg: Badge) => {
|
||||||
|
if (bdg.name === val) {
|
||||||
|
badge.value = bdg
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
badge.value = { ...defaultBadge }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const saveBadge = (close: () => void) => {
|
||||||
|
if (props.badgeName == 'new') {
|
||||||
|
createBadge(close)
|
||||||
|
} else {
|
||||||
|
updateBadge(close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateBadge = async (close: () => void) => {
|
||||||
|
if (props.badgeName != badge.value?.title) {
|
||||||
|
await renameDoc()
|
||||||
|
}
|
||||||
|
setValue(close)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renameDoc = async () => {
|
||||||
|
await call('frappe.client.rename_doc', {
|
||||||
|
doctype: 'LMS Badge',
|
||||||
|
old_name: props.badgeName,
|
||||||
|
new_name: badge.value?.title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const setValue = (close: () => void) => {
|
||||||
|
badges.value?.setValue.submit(
|
||||||
|
{
|
||||||
|
...badge.value,
|
||||||
|
name: badge.value.title,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
badges.value?.reload()
|
||||||
|
close()
|
||||||
|
toast.success(__('Badge updated successfully'))
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
close()
|
||||||
|
toast.error(cleanError(err.messages[0]) || err)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createBadge = (close: () => void) => {
|
||||||
|
badges.value?.insert.submit(
|
||||||
|
{
|
||||||
|
...badge.value,
|
||||||
|
name: badge.value.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
badges.value?.reload()
|
||||||
|
close()
|
||||||
|
toast.success(__('Badge created successfully'))
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
close()
|
||||||
|
toast.error(cleanError(err.messages[0]) || __('Error creating badge'))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceDoctypeOptions = computed(() => {
|
||||||
|
return [
|
||||||
|
{ label: __('Course'), value: 'LMS Course' },
|
||||||
|
{ label: __('Batch'), value: 'LMS Batch' },
|
||||||
|
{ label: __('User'), value: 'Member' },
|
||||||
|
{ label: __('Quiz Submission'), value: 'LMS Quiz Submission' },
|
||||||
|
{ label: __('Assignment Submission'), value: 'LMS Assignment Submission' },
|
||||||
|
{
|
||||||
|
label: __('Programming Exercise Submission'),
|
||||||
|
value: 'LMS Programming Exercise Submission',
|
||||||
|
},
|
||||||
|
{ label: __('Course Enrollment'), value: 'LMS Enrollment' },
|
||||||
|
{ label: __('Batch Enrollment'), value: 'LMS Batch Enrollment' },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const eventOptions = computed(() => {
|
||||||
|
let options = ['New', 'Value Change', 'Auto Assign']
|
||||||
|
return options.map((event) => ({ label: __(event), value: event }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const userFieldOptions = computed(() => {
|
||||||
|
return [
|
||||||
|
{ label: __('Member'), value: 'member' },
|
||||||
|
{ label: __('Owner'), value: 'owner' },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
247
frontend/src/components/Settings/Badges.vue
Normal file
247
frontend/src/components/Settings/Badges.vue
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
<template>
|
||||||
|
<BadgeAssignments
|
||||||
|
v-if="showAssignments"
|
||||||
|
v-model="showAssignments"
|
||||||
|
:badgeName="showAssignmentsFor"
|
||||||
|
/>
|
||||||
|
<div v-else 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>
|
||||||
|
<Button @click="openForm('new')">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="h-3 w-3 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('New') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div v-if="badges.data?.length" class="overflow-y-scroll">
|
||||||
|
<ListView
|
||||||
|
:columns="columns"
|
||||||
|
:rows="badges.data"
|
||||||
|
row-key="name"
|
||||||
|
:options="{
|
||||||
|
showTooltip: false,
|
||||||
|
selectable: 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 columns" :key="item.key">
|
||||||
|
<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 badges.data" :key="row.name">
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||||
|
<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-if="column.key == 'reference_doctype'">
|
||||||
|
{{
|
||||||
|
doctypeLabel[
|
||||||
|
row[column.key] as keyof typeof doctypeLabel
|
||||||
|
] || row[column.key]
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<FormControl
|
||||||
|
v-else-if="column.key == 'grant_only_once'"
|
||||||
|
v-model="row[column.key]"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else-if="column.key != 'action'"
|
||||||
|
class="leading-5 text-sm"
|
||||||
|
>
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
<Dropdown
|
||||||
|
v-else
|
||||||
|
:options="getMoreOptions(row.name)"
|
||||||
|
:button="{
|
||||||
|
icon: 'more-horizontal',
|
||||||
|
onblur: (e: Event) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
placement="right"
|
||||||
|
/>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</ListRows>
|
||||||
|
</ListView>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BadgeForm
|
||||||
|
v-model="showForm"
|
||||||
|
:badgeName="selectedBadge"
|
||||||
|
v-model:badges="badges"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
createListResource,
|
||||||
|
Dropdown,
|
||||||
|
FeatherIcon,
|
||||||
|
FormControl,
|
||||||
|
ListView,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListRows,
|
||||||
|
ListRow,
|
||||||
|
ListRowItem,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { Plus } from 'lucide-vue-next'
|
||||||
|
import { cleanError } from '@/utils'
|
||||||
|
import BadgeForm from '@/components/Settings/BadgeForm.vue'
|
||||||
|
import BadgeAssignments from '@/components/Settings/BadgeAssignments.vue'
|
||||||
|
|
||||||
|
const showForm = ref<boolean>(false)
|
||||||
|
const selectedBadge = ref<string | null>(null)
|
||||||
|
const showAssignments = ref<boolean>(false)
|
||||||
|
const showAssignmentsFor = ref<string | null>(null)
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const badges = createListResource({
|
||||||
|
doctype: 'LMS Badge',
|
||||||
|
fields: [
|
||||||
|
'name',
|
||||||
|
'title',
|
||||||
|
'enabled',
|
||||||
|
'description',
|
||||||
|
'image',
|
||||||
|
'grant_only_once',
|
||||||
|
'event',
|
||||||
|
'reference_doctype',
|
||||||
|
'condition',
|
||||||
|
'user_field',
|
||||||
|
'field_to_check',
|
||||||
|
],
|
||||||
|
order_by: 'creation desc',
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const getMoreOptions = (badgeName: string) => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: __('Edit'),
|
||||||
|
icon: 'edit',
|
||||||
|
onClick() {
|
||||||
|
openForm(badgeName)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Assignments'),
|
||||||
|
icon: 'download',
|
||||||
|
onClick() {
|
||||||
|
showAssignmentsFor.value = badgeName
|
||||||
|
showAssignments.value = true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Delete'),
|
||||||
|
icon: 'trash-2',
|
||||||
|
onClick() {
|
||||||
|
deleteBadge(badgeName)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const openForm = (badgeName: string) => {
|
||||||
|
selectedBadge.value = badgeName
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteBadge = (badgeName: string) => {
|
||||||
|
badges.delete
|
||||||
|
.submit(badgeName)
|
||||||
|
.then(() => {
|
||||||
|
badges.reload()
|
||||||
|
toast.success(__('Badge deleted successfully'))
|
||||||
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
toast.error(cleanError(err.messages[0]) || __('Error deleting badge'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const doctypeLabel = computed(() => {
|
||||||
|
return {
|
||||||
|
'LMS Course': __('Course'),
|
||||||
|
'LMS Batch': __('Batch'),
|
||||||
|
'LMS Enrollment': __('Course Enrollment'),
|
||||||
|
'LMS Batch Enrollment': __('Batch Enrollment'),
|
||||||
|
'LMS Quiz Submission': __('Quiz Submission'),
|
||||||
|
'LMS Assignment Submission': __('Assignment Submission'),
|
||||||
|
'LMS Programming Exercise Submission': __(
|
||||||
|
'Programming Exercise Submission'
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: __('Badge'),
|
||||||
|
key: 'title',
|
||||||
|
icon: 'award',
|
||||||
|
align: 'left',
|
||||||
|
width: '25%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Assigned For'),
|
||||||
|
key: 'reference_doctype',
|
||||||
|
icon: 'info',
|
||||||
|
align: 'left',
|
||||||
|
width: '35%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Status'),
|
||||||
|
key: 'enabled',
|
||||||
|
icon: 'check-square',
|
||||||
|
align: 'left',
|
||||||
|
width: '15%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Grant Only Once'),
|
||||||
|
key: 'grant_only_once',
|
||||||
|
icon: 'check',
|
||||||
|
align: 'center',
|
||||||
|
width: '20%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'action',
|
||||||
|
align: 'right',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col justify-between min-h-0">
|
<div class="flex flex-col justify-between h-full">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="font-semibold mb-1 text-ink-gray-9">
|
<div class="font-semibold mb-1 text-ink-gray-9">
|
||||||
@@ -17,18 +17,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-y-auto">
|
<div class="overflow-y-auto">
|
||||||
<SettingFields :fields="fields" :data="data.data" />
|
<SettingFields :fields="fields" :data="branding.data" />
|
||||||
|
</div>
|
||||||
<div class="flex flex-row-reverse mt-auto">
|
<div class="flex flex-row-reverse mt-auto">
|
||||||
<Button variant="solid" :loading="saveSettings.loading" @click="update">
|
<Button variant="solid" :loading="saveSettings.loading" @click="update">
|
||||||
{{ __('Update') }}
|
{{ __('Update') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createResource, Button, Badge } from 'frappe-ui'
|
import { createResource, Button, Badge } from 'frappe-ui'
|
||||||
import SettingFields from '@/components/SettingFields.vue'
|
import SettingFields from '@/components/Settings/SettingFields.vue'
|
||||||
import { watch, ref } from 'vue'
|
import { watch, ref } from 'vue'
|
||||||
|
|
||||||
const isDirty = ref(false)
|
const isDirty = ref(false)
|
||||||
@@ -38,10 +38,6 @@ const props = defineProps({
|
|||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
data: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
label: {
|
label: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -51,6 +47,12 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const branding = createResource({
|
||||||
|
url: 'lms.lms.api.get_branding',
|
||||||
|
auto: true,
|
||||||
|
cache: 'brand',
|
||||||
|
})
|
||||||
|
|
||||||
const saveSettings = createResource({
|
const saveSettings = createResource({
|
||||||
url: 'frappe.client.set_value',
|
url: 'frappe.client.set_value',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
@@ -64,7 +66,7 @@ const saveSettings = createResource({
|
|||||||
|
|
||||||
const update = () => {
|
const update = () => {
|
||||||
let fieldsToSave = {}
|
let fieldsToSave = {}
|
||||||
let imageFields = ['favicon', 'banner_image', 'footer_logo']
|
let imageFields = ['favicon', 'banner_image']
|
||||||
props.fields.forEach((f) => {
|
props.fields.forEach((f) => {
|
||||||
if (imageFields.includes(f.name)) {
|
if (imageFields.includes(f.name)) {
|
||||||
fieldsToSave[f.name] = f.value ? f.value.file_url : null
|
fieldsToSave[f.name] = f.value ? f.value.file_url : null
|
||||||
@@ -72,6 +74,8 @@ const update = () => {
|
|||||||
fieldsToSave[f.name] = f.value
|
fieldsToSave[f.name] = f.value
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
fieldsToSave['app_logo'] = fieldsToSave['banner_image']
|
||||||
saveSettings.submit(
|
saveSettings.submit(
|
||||||
{
|
{
|
||||||
fields: fieldsToSave,
|
fields: fieldsToSave,
|
||||||
@@ -84,9 +88,31 @@ const update = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(props.data, (newData) => {
|
watch(branding, (updatedDoc) => {
|
||||||
if (newData && !isDirty.value) {
|
let textFields = []
|
||||||
|
let imageFields = []
|
||||||
|
|
||||||
|
props.fields.forEach((f) => {
|
||||||
|
if (f.type === 'Upload') {
|
||||||
|
imageFields.push(f.name)
|
||||||
|
} else {
|
||||||
|
textFields.push(f.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
textFields.forEach((field) => {
|
||||||
|
if (updatedDoc.data[field] != updatedDoc.previousData[field]) {
|
||||||
isDirty.value = true
|
isDirty.value = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
imageFields.forEach((field) => {
|
||||||
|
if (
|
||||||
|
updatedDoc.data[field] &&
|
||||||
|
updatedDoc.data[field].file_url != updatedDoc.previousData[field].file_url
|
||||||
|
) {
|
||||||
|
isDirty.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
216
frontend/src/components/Settings/Categories.vue
Normal file
216
frontend/src/components/Settings/Categories.vue
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
<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-xs text-ink-gray-5">
|
||||||
|
{{ __(description) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-5">
|
||||||
|
<div
|
||||||
|
class="flex items-center space-x-1 text-ink-amber-3 border border-outline-amber-1 bg-surface-amber-1 rounded-lg px-2 py-1"
|
||||||
|
v-if="saving"
|
||||||
|
>
|
||||||
|
<LoadingIndicator class="size-2" />
|
||||||
|
<span class="text-xs">
|
||||||
|
{{ __('saving...') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button @click="() => showCategoryForm()">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
|
||||||
|
<X v-else class="h-3 w-3 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ showForm ? __('Close') : __('New') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showForm"
|
||||||
|
class="flex items-center justify-between my-4 space-x-2"
|
||||||
|
>
|
||||||
|
<FormControl
|
||||||
|
ref="categoryInput"
|
||||||
|
v-model="category"
|
||||||
|
:placeholder="__('Category Name')"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Button @click="addCategory()" variant="subtle">
|
||||||
|
{{ __('Add') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-y-scroll">
|
||||||
|
<div class="divide-y space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(cat, index) in categories.data"
|
||||||
|
:key="cat.name"
|
||||||
|
class="pt-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="editing?.name !== cat.name"
|
||||||
|
class="flex items-center justify-between group text-sm"
|
||||||
|
>
|
||||||
|
<div @dblclick="allowEdit(cat, index)">
|
||||||
|
{{ cat.category }}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
theme="red"
|
||||||
|
class="invisible group-hover:visible"
|
||||||
|
@click="deleteCategory(cat.name)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Trash2 class="size-4 stroke-1.5 text-ink-red-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FormControl
|
||||||
|
v-else
|
||||||
|
:ref="(el) => (editInputRef[index] = el)"
|
||||||
|
v-model="editedValue"
|
||||||
|
type="text"
|
||||||
|
class="w-full"
|
||||||
|
@keyup.enter="saveChanges(cat.name, editedValue)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
LoadingIndicator,
|
||||||
|
createListResource,
|
||||||
|
createResource,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { Plus, Trash2, X } from 'lucide-vue-next'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { cleanError } from '@/utils'
|
||||||
|
|
||||||
|
const showForm = ref(false)
|
||||||
|
const category = ref(null)
|
||||||
|
const categoryInput = ref(null)
|
||||||
|
const saving = ref(false)
|
||||||
|
const editing = ref(null)
|
||||||
|
const editedValue = ref('')
|
||||||
|
const editInputRef = ref([])
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const categories = createListResource({
|
||||||
|
doctype: 'LMS Category',
|
||||||
|
fields: ['name', 'category'],
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const addCategory = () => {
|
||||||
|
categories.insert.submit(
|
||||||
|
{
|
||||||
|
category: category.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
categories.reload()
|
||||||
|
category.value = null
|
||||||
|
showForm.value = false
|
||||||
|
toast.success(__('Category added successfully'))
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
toast.error(__(cleanError(err.messages[0]) || 'Unable to add category'))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showCategoryForm = () => {
|
||||||
|
showForm.value = !showForm.value
|
||||||
|
setTimeout(() => {
|
||||||
|
categoryInput.value.$el.querySelector('input').focus()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCategory = createResource({
|
||||||
|
url: 'frappe.client.rename_doc',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'LMS Category',
|
||||||
|
old_name: values.name,
|
||||||
|
new_name: values.category,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const update = (name, value) => {
|
||||||
|
saving.value = true
|
||||||
|
updateCategory.submit(
|
||||||
|
{
|
||||||
|
name: name,
|
||||||
|
category: value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
saving.value = false
|
||||||
|
categories.reload()
|
||||||
|
editing.value = null
|
||||||
|
editedValue.value = ''
|
||||||
|
toast.success(__('Category updated successfully'))
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
saving.value = false
|
||||||
|
editing.value = null
|
||||||
|
editedValue.value = ''
|
||||||
|
toast.error(
|
||||||
|
__(cleanError(err.messages[0]) || 'Unable to update category')
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteCategory = (name) => {
|
||||||
|
saving.value = true
|
||||||
|
categories.delete.submit(name, {
|
||||||
|
onSuccess() {
|
||||||
|
saving.value = false
|
||||||
|
categories.reload()
|
||||||
|
toast.success(__('Category deleted successfully'))
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
saving.value = false
|
||||||
|
toast.error(
|
||||||
|
__(cleanError(err.messages[0]) || 'Unable to delete category')
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveChanges = (name, value) => {
|
||||||
|
saving.value = true
|
||||||
|
update(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowEdit = (cat, index) => {
|
||||||
|
editing.value = cat
|
||||||
|
editedValue.value = cat.category
|
||||||
|
setTimeout(() => {
|
||||||
|
editInputRef.value[index].$el.querySelector('input').focus()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
160
frontend/src/components/Settings/EmailTemplates.vue
Normal file
160
frontend/src/components/Settings/EmailTemplates.vue
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<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-xs text-ink-gray-5">
|
||||||
|
{{ __(description) }}
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-5">
|
||||||
|
<Button @click="openTemplateForm('new')">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="h-3 w-3 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('New') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="emailTemplates.data?.length" class="overflow-y-scroll">
|
||||||
|
<ListView
|
||||||
|
:columns="columns"
|
||||||
|
:rows="emailTemplates.data"
|
||||||
|
row-key="name"
|
||||||
|
:options="{
|
||||||
|
showTooltip: false,
|
||||||
|
onRowClick: (row) => {
|
||||||
|
openTemplateForm(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 }">
|
||||||
|
<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 emailTemplates.data">
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||||
|
<div 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="removeTemplate(selections, unselectAll)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListSelectBanner>
|
||||||
|
</ListView>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<EmailTemplateModal
|
||||||
|
v-model="showForm"
|
||||||
|
v-model:emailTemplates="emailTemplates"
|
||||||
|
:templateID="selectedTemplate"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
call,
|
||||||
|
createListResource,
|
||||||
|
ListView,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListSelectBanner,
|
||||||
|
ListRows,
|
||||||
|
ListRow,
|
||||||
|
ListRowItem,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||||
|
import EmailTemplateModal from '@/components/Modals/EmailTemplateModal.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const showForm = ref(false)
|
||||||
|
const readOnlyMode = window.read_only_mode
|
||||||
|
const selectedTemplate = 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 removeTemplate = (selections, unselectAll) => {
|
||||||
|
call('lms.lms.api.delete_documents', {
|
||||||
|
doctype: 'Email Template',
|
||||||
|
documents: Array.from(selections),
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
emailTemplates.reload()
|
||||||
|
toast.success(__('Email Templates deleted successfully'))
|
||||||
|
unselectAll()
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(
|
||||||
|
cleanError(err.messages[0]) || __('Error deleting email templates')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openTemplateForm = (templateID) => {
|
||||||
|
if (readOnlyMode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedTemplate.value = templateID
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Name',
|
||||||
|
key: 'name',
|
||||||
|
width: '20rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Subject',
|
||||||
|
key: 'subject',
|
||||||
|
width: '25rem',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
204
frontend/src/components/Settings/Evaluators.vue
Normal file
204
frontend/src/components/Settings/Evaluators.vue
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<template>
|
||||||
|
<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">
|
||||||
|
{{ __(label) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-ink-gray-6 leading-5">
|
||||||
|
{{ __(description) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex item-center space-x-2">
|
||||||
|
<Button variant="solid" @click="() => (showForm = !showForm)">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('New') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 pb-5">
|
||||||
|
<FormControl
|
||||||
|
v-model="search"
|
||||||
|
:placeholder="__('Search')"
|
||||||
|
type="text"
|
||||||
|
:debounce="300"
|
||||||
|
class="w-1/4 mb-4"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
|
||||||
|
</template>
|
||||||
|
</FormControl>
|
||||||
|
<div class="overflow-auto h-[60vh]">
|
||||||
|
<div class="divide-y">
|
||||||
|
<div
|
||||||
|
v-for="evaluator in evaluators.data"
|
||||||
|
:key="evaluator.evaluator"
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between group py-3">
|
||||||
|
<div
|
||||||
|
class="flex items-center space-x-3"
|
||||||
|
@click="openProfile(evaluator.username)"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:image="evaluator.user_image"
|
||||||
|
:label="evaluator.full_name"
|
||||||
|
size="xl"
|
||||||
|
/>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-base font-semibold text-ink-gray-9">
|
||||||
|
{{ evaluator.full_name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-ink-gray-5">
|
||||||
|
{{ evaluator.evaluator }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="invisible group-hover:visible">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="deleteEvaluator(evaluator.evaluator)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Trash2 class="size-4 stroke-1.5 text-ink-red-3" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="evaluators.length && hasNextPage"
|
||||||
|
class="flex justify-center mt-4"
|
||||||
|
>
|
||||||
|
<Button @click="evaluators.reload()">
|
||||||
|
<template #prefix>
|
||||||
|
<RefreshCw class="h-3 w-3 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Load More') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
call,
|
||||||
|
createListResource,
|
||||||
|
Dialog,
|
||||||
|
FormControl,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { Plus, Search, Trash2, RefreshCw } from 'lucide-vue-next'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const show = defineModel('show')
|
||||||
|
const search = ref('')
|
||||||
|
const showForm = ref(false)
|
||||||
|
const email = ref('')
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const evaluators = createListResource({
|
||||||
|
doctype: 'Course Evaluator',
|
||||||
|
fields: ['evaluator', 'username', 'full_name', 'user_image'],
|
||||||
|
auto: true,
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(search, () => {
|
||||||
|
evaluators.update({
|
||||||
|
filters: {
|
||||||
|
full_name: ['like', `%${search.value}%`],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
evaluators.reload()
|
||||||
|
})
|
||||||
|
|
||||||
|
const openProfile = (username: string) => {
|
||||||
|
show.value = false
|
||||||
|
router.push({
|
||||||
|
name: 'Profile',
|
||||||
|
params: {
|
||||||
|
username: username,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteEvaluator = (evaluator: string) => {
|
||||||
|
call('lms.lms.api.delete_evaluator', {
|
||||||
|
evaluator: evaluator,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success(__('Evaluator deleted successfully'))
|
||||||
|
evaluators.reload()
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
toast.error(__(error.messages[0] || error.messages))
|
||||||
|
console.error('Error deleting evaluator:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -5,53 +5,37 @@
|
|||||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||||
{{ __(label) }}
|
{{ __(label) }}
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="text-xs text-ink-gray-5">
|
<div class="text-ink-gray-6 leading-5">
|
||||||
{{ __(description) }}
|
{{ __(description) }}
|
||||||
</div> -->
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex item-center space-x-2">
|
<div class="flex item-center space-x-2">
|
||||||
|
<Button variant="solid" @click="() => (showForm = !showForm)">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('New') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 pb-10">
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="search"
|
v-model="search"
|
||||||
:placeholder="__('Search')"
|
:placeholder="__('Search')"
|
||||||
type="text"
|
type="text"
|
||||||
:debounce="300"
|
:debounce="300"
|
||||||
/>
|
class="w-1/4 mb-4"
|
||||||
<Button @click="() => (showForm = !showForm)">
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus v-if="!showForm" class="size-4 stroke-1.5" />
|
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
|
||||||
<X v-else class="size-4 stroke-1.5" />
|
|
||||||
</template>
|
</template>
|
||||||
{{ showForm ? __('Close') : __('New') }}
|
</FormControl>
|
||||||
</Button>
|
<div class="overflow-y-scroll h-[60vh]">
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Form to add new member -->
|
|
||||||
<div v-if="showForm" class="flex items-center space-x-2 my-4">
|
|
||||||
<FormControl
|
|
||||||
v-model="member.email"
|
|
||||||
:placeholder="__('Email')"
|
|
||||||
type="email"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<FormControl
|
|
||||||
v-model="member.first_name"
|
|
||||||
:placeholder="__('First Name')"
|
|
||||||
type="text"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<Button @click="addMember()" variant="subtle">
|
|
||||||
{{ __('Add') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-2 pb-10 overflow-auto">
|
|
||||||
<!-- Member list -->
|
|
||||||
<div class="overflow-y-scroll">
|
|
||||||
<ul class="divide-y">
|
<ul class="divide-y">
|
||||||
<li
|
<li
|
||||||
v-for="member in memberList"
|
v-for="member in memberList"
|
||||||
class="grid grid-cols-3 gap-10 py-2 cursor-pointer"
|
class="flex items-center justify-between py-2 cursor-pointer"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@click="openProfile(member.username)"
|
@click="openProfile(member.username)"
|
||||||
@@ -60,27 +44,13 @@
|
|||||||
<Avatar
|
<Avatar
|
||||||
:image="member.user_image"
|
:image="member.user_image"
|
||||||
:label="member.full_name"
|
:label="member.full_name"
|
||||||
size="lg"
|
size="xl"
|
||||||
/>
|
/>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="text-ink-gray-9">
|
<div class="text-ink-gray-9">
|
||||||
{{ member.full_name }}
|
{{ member.full_name }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
class="px-1"
|
|
||||||
v-if="member.role && getRole(member.role) !== 'Student'"
|
|
||||||
>
|
|
||||||
<Badge
|
|
||||||
:variant="'subtle'"
|
|
||||||
:ref_for="true"
|
|
||||||
theme="blue"
|
|
||||||
size="sm"
|
|
||||||
label="Badge"
|
|
||||||
>
|
|
||||||
{{ getRole(member.role) }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-ink-gray-7">
|
<div class="text-sm text-ink-gray-7">
|
||||||
{{ member.name }}
|
{{ member.name }}
|
||||||
@@ -88,16 +58,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-center text-ink-gray-7 text-sm"
|
class="flex items-center space-x-1 bg-surface-gray-2 px-2 py-1.5 rounded-md"
|
||||||
|
v-if="member.role && member.role !== 'LMS Student'"
|
||||||
>
|
>
|
||||||
<div v-if="member.last_active">
|
<Shield class="size-4 stroke-1.5" />
|
||||||
{{ dayjs(member.last_active).format('DD MMM, YYYY HH:mm a') }}
|
<span class="text-sm">
|
||||||
</div>
|
{{ getRole(member.role) }}
|
||||||
<div v-else>-</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-if="memberList.length && hasNextPage"
|
v-if="memberList.length && hasNextPage"
|
||||||
class="flex justify-center mt-4"
|
class="flex justify-center mt-4"
|
||||||
@@ -111,36 +81,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { createResource, Avatar, Button, FormControl, Badge } from 'frappe-ui'
|
import {
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
createResource,
|
||||||
|
Dialog,
|
||||||
|
FormControl,
|
||||||
|
} from 'frappe-ui'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ref, watch, reactive, inject } from 'vue'
|
import { ref, watch, reactive, inject } from 'vue'
|
||||||
import { RefreshCw, Plus, X } from 'lucide-vue-next'
|
import { RefreshCw, Plus, Search, Shield } from 'lucide-vue-next'
|
||||||
import { useOnboarding } from 'frappe-ui/frappe'
|
import { useOnboarding } from 'frappe-ui/frappe'
|
||||||
|
import type { User } from '@/components/Settings/types'
|
||||||
|
|
||||||
interface User {
|
type Member = {
|
||||||
data: {
|
|
||||||
email: string
|
|
||||||
name: string
|
|
||||||
enabled: boolean
|
|
||||||
user_image: string
|
|
||||||
full_name: string
|
|
||||||
user_type: ['System User', 'Website User']
|
|
||||||
username: string
|
username: string
|
||||||
is_moderator: boolean
|
full_name: string
|
||||||
is_system_manager: boolean
|
name: string
|
||||||
is_evaluator: boolean
|
role?: string
|
||||||
is_instructor: boolean
|
user_image?: string
|
||||||
is_fc_site: boolean
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const show = defineModel('show')
|
const show = defineModel('show')
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const start = ref(0)
|
const start = ref(0)
|
||||||
const memberList = ref([])
|
const memberList = ref<Member[]>([])
|
||||||
const hasNextPage = ref(false)
|
const hasNextPage = ref(false)
|
||||||
const showForm = ref(false)
|
const showForm = ref(false)
|
||||||
const dayjs = inject('$dayjs')
|
const dayjs = inject('$dayjs')
|
||||||
@@ -174,7 +177,7 @@ const members = createResource({
|
|||||||
start: start.value,
|
start: start.value,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess(data) {
|
onSuccess(data: Member[]) {
|
||||||
memberList.value = memberList.value.concat(data)
|
memberList.value = memberList.value.concat(data)
|
||||||
start.value = start.value + 20
|
start.value = start.value + 20
|
||||||
hasNextPage.value = data.length === 20
|
hasNextPage.value = data.length === 20
|
||||||
@@ -182,7 +185,7 @@ const members = createResource({
|
|||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const openProfile = (username) => {
|
const openProfile = (username: string) => {
|
||||||
show.value = false
|
show.value = false
|
||||||
router.push({
|
router.push({
|
||||||
name: 'Profile',
|
name: 'Profile',
|
||||||
@@ -194,7 +197,7 @@ const openProfile = (username) => {
|
|||||||
|
|
||||||
const newMember = createResource({
|
const newMember = createResource({
|
||||||
url: 'frappe.client.insert',
|
url: 'frappe.client.insert',
|
||||||
makeParams(values) {
|
makeParams() {
|
||||||
return {
|
return {
|
||||||
doc: {
|
doc: {
|
||||||
doctype: 'User',
|
doctype: 'User',
|
||||||
@@ -204,13 +207,12 @@ const newMember = createResource({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
auto: false,
|
auto: false,
|
||||||
onSuccess(data) {
|
onSuccess(data: Member) {
|
||||||
show.value = false
|
show.value = false
|
||||||
|
|
||||||
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
|
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
|
||||||
|
|
||||||
router.push({
|
router.push({
|
||||||
name: 'Profile',
|
name: 'ProfileRoles',
|
||||||
params: {
|
params: {
|
||||||
username: data.username,
|
username: data.username,
|
||||||
},
|
},
|
||||||
@@ -218,8 +220,9 @@ const newMember = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const addMember = () => {
|
const addMember = (close: () => void) => {
|
||||||
newMember.reload()
|
newMember.reload()
|
||||||
|
close()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(search, () => {
|
watch(search, () => {
|
||||||
@@ -228,8 +231,8 @@ watch(search, () => {
|
|||||||
members.reload()
|
members.reload()
|
||||||
})
|
})
|
||||||
|
|
||||||
const getRole = (role) => {
|
const getRole = (role: string) => {
|
||||||
const map = {
|
const map: Record<string, string> = {
|
||||||
'LMS Student': 'Student',
|
'LMS Student': 'Student',
|
||||||
'Course Creator': 'Instructor',
|
'Course Creator': 'Instructor',
|
||||||
Moderator: 'Moderator',
|
Moderator: 'Moderator',
|
||||||
@@ -12,13 +12,13 @@
|
|||||||
/> -->
|
/> -->
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-y-scroll">
|
<div class="overflow-y-scroll">
|
||||||
<div class="flex space-x-4">
|
<div class="flex flex-col divide-y">
|
||||||
<SettingFields :fields="fields" :data="data.doc" class="w-1/2" />
|
<SettingFields :fields="fields" :data="data.doc" />
|
||||||
<SettingFields
|
<SettingFields
|
||||||
v-if="paymentGateway.data"
|
v-if="paymentGateway.data"
|
||||||
:fields="paymentGateway.data.fields"
|
:fields="paymentGateway.data.fields"
|
||||||
:data="paymentGateway.data.data"
|
:data="paymentGateway.data.data"
|
||||||
class="w-1/2"
|
class="pt-5 my-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -30,9 +30,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import SettingFields from '@/components/SettingFields.vue'
|
import SettingFields from '@/components/Settings/SettingFields.vue'
|
||||||
import { createResource, Badge, Button } from 'frappe-ui'
|
import { createResource, Badge, Button } from 'frappe-ui'
|
||||||
import { watch, ref } from 'vue'
|
import { watch } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
label: {
|
label: {
|
||||||
@@ -60,9 +60,28 @@ const paymentGateway = createResource({
|
|||||||
payment_gateway: props.data.doc.payment_gateway,
|
payment_gateway: props.data.doc.payment_gateway,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
transform(data) {
|
||||||
|
arrangeFields(data.fields)
|
||||||
|
return data
|
||||||
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const arrangeFields = (fields) => {
|
||||||
|
fields = fields.sort((a, b) => {
|
||||||
|
if (a.type === 'Upload' && b.type !== 'Upload') {
|
||||||
|
return 1
|
||||||
|
} else if (a.type !== 'Upload' && b.type === 'Upload') {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
fields.splice(3, 0, {
|
||||||
|
type: 'Column Break',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const saveSettings = createResource({
|
const saveSettings = createResource({
|
||||||
url: 'frappe.client.set_value',
|
url: 'frappe.client.set_value',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Button, Badge, toast } from 'frappe-ui'
|
import { Button, Badge, toast } from 'frappe-ui'
|
||||||
import SettingFields from '@/components/SettingFields.vue'
|
import SettingFields from '@/components/Settings/SettingFields.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
fields: {
|
fields: {
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<div v-for="(column, index) in columns" :key="index">
|
<div v-for="(column, index) in columns" :key="index">
|
||||||
<div
|
<div
|
||||||
class="flex flex-col space-y-5"
|
class="flex flex-col space-y-5"
|
||||||
:class="columns.length > 1 ? 'w-72' : 'w-full'"
|
:class="columns.length > 1 ? 'w-[21rem]' : 'w-full'"
|
||||||
>
|
>
|
||||||
<div v-for="field in column">
|
<div v-for="field in column">
|
||||||
<Link
|
<Link
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
v-model="data[field.name]"
|
v-model="data[field.name]"
|
||||||
:doctype="field.doctype"
|
:doctype="field.doctype"
|
||||||
:label="__(field.label)"
|
:label="__(field.label)"
|
||||||
|
:description="__(field.description)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-else-if="field.type == 'Code'">
|
<div v-else-if="field.type == 'Code'">
|
||||||
@@ -54,11 +55,13 @@
|
|||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="flex items-center text-sm space-x-2">
|
<div class="flex items-center text-sm space-x-2">
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-center rounded border border-outline-gray-modals bg-white w-[10rem] py-2"
|
class="flex items-center justify-center rounded border border-outline-gray-1 bg-surface-gray-2"
|
||||||
|
:class="field.size == 'lg' ? 'px-5 py-5' : 'px-20 py-8'"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="data[field.name]?.file_url || data[field.name]"
|
:src="data[field.name]?.file_url || data[field.name]"
|
||||||
class="w-[80%] rounded"
|
class="rounded"
|
||||||
|
:class="field.size == 'lg' ? 'w-36' : 'size-6'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col flex-wrap">
|
<div class="flex flex-col flex-wrap">
|
||||||
@@ -100,6 +103,7 @@
|
|||||||
:rows="field.rows"
|
:rows="field.rows"
|
||||||
:options="field.options"
|
:options="field.options"
|
||||||
:description="field.description"
|
:description="field.description"
|
||||||
|
:class="columns.length > 1 ? 'w-full' : 'w-1/2'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,22 +34,16 @@
|
|||||||
:key="activeTab.label"
|
:key="activeTab.label"
|
||||||
class="flex flex-1 flex-col px-10 py-8 bg-surface-modal"
|
class="flex flex-1 flex-col px-10 py-8 bg-surface-modal"
|
||||||
>
|
>
|
||||||
<Members
|
<component
|
||||||
v-if="activeTab.label === 'Members'"
|
v-if="activeTab.template"
|
||||||
:label="activeTab.label"
|
:is="activeTab.template"
|
||||||
:description="activeTab.description"
|
v-bind="{
|
||||||
v-model:show="show"
|
label: activeTab.label,
|
||||||
/>
|
description: activeTab.description,
|
||||||
<Evaluators
|
...(activeTab.label === 'Branding'
|
||||||
v-else-if="activeTab.label === 'Evaluators'"
|
? { fields: activeTab.fields }
|
||||||
:label="activeTab.label"
|
: {}),
|
||||||
:description="activeTab.description"
|
}"
|
||||||
v-model:show="show"
|
|
||||||
/>
|
|
||||||
<Categories
|
|
||||||
v-else-if="activeTab.label === 'Categories'"
|
|
||||||
:label="activeTab.label"
|
|
||||||
:description="activeTab.description"
|
|
||||||
/>
|
/>
|
||||||
<PaymentSettings
|
<PaymentSettings
|
||||||
v-else-if="activeTab.label === 'Payment Gateway'"
|
v-else-if="activeTab.label === 'Payment Gateway'"
|
||||||
@@ -58,13 +52,6 @@
|
|||||||
:data="data"
|
:data="data"
|
||||||
:fields="activeTab.fields"
|
:fields="activeTab.fields"
|
||||||
/>
|
/>
|
||||||
<BrandSettings
|
|
||||||
v-else-if="activeTab.label === 'Branding'"
|
|
||||||
:label="activeTab.label"
|
|
||||||
:description="activeTab.description"
|
|
||||||
:fields="activeTab.fields"
|
|
||||||
:data="branding"
|
|
||||||
/>
|
|
||||||
<SettingDetails
|
<SettingDetails
|
||||||
v-else
|
v-else
|
||||||
:fields="activeTab.fields"
|
:fields="activeTab.fields"
|
||||||
@@ -78,16 +65,19 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, createDocumentResource, createResource } from 'frappe-ui'
|
import { Dialog, createDocumentResource } from 'frappe-ui'
|
||||||
import { ref, computed, watch } from 'vue'
|
import { computed, markRaw, ref, watch } from 'vue'
|
||||||
import { useSettings } from '@/stores/settings'
|
import { useSettings } from '@/stores/settings'
|
||||||
import SettingDetails from '../SettingDetails.vue'
|
import SettingDetails from '@/components/Settings/SettingDetails.vue'
|
||||||
import SidebarLink from '@/components/SidebarLink.vue'
|
import SidebarLink from '@/components/SidebarLink.vue'
|
||||||
import Members from '@/components/Members.vue'
|
import Members from '@/components/Settings/Members.vue'
|
||||||
import Evaluators from '@/components/Evaluators.vue'
|
import Evaluators from '@/components/Settings/Evaluators.vue'
|
||||||
import Categories from '@/components/Categories.vue'
|
import Categories from '@/components/Settings/Categories.vue'
|
||||||
import BrandSettings from '@/components/BrandSettings.vue'
|
import EmailTemplates from '@/components/Settings/EmailTemplates.vue'
|
||||||
import PaymentSettings from '@/components/PaymentSettings.vue'
|
import BrandSettings from '@/components/Settings/BrandSettings.vue'
|
||||||
|
import PaymentSettings from '@/components/Settings/PaymentSettings.vue'
|
||||||
|
import ZoomSettings from '@/components/Settings/ZoomSettings.vue'
|
||||||
|
import Badges from '@/components/Settings/Badges.vue'
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const doctype = ref('LMS Settings')
|
const doctype = ref('LMS Settings')
|
||||||
@@ -102,12 +92,6 @@ const data = createDocumentResource({
|
|||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const branding = createResource({
|
|
||||||
url: 'lms.lms.api.get_branding',
|
|
||||||
auto: true,
|
|
||||||
cache: 'brand',
|
|
||||||
})
|
|
||||||
|
|
||||||
const tabsStructure = computed(() => {
|
const tabsStructure = computed(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -118,13 +102,6 @@ const tabsStructure = computed(() => {
|
|||||||
label: 'General',
|
label: 'General',
|
||||||
icon: 'Wrench',
|
icon: 'Wrench',
|
||||||
fields: [
|
fields: [
|
||||||
{
|
|
||||||
label: 'Enable Learning Paths',
|
|
||||||
name: 'enable_learning_paths',
|
|
||||||
description:
|
|
||||||
'This will enforce students to go through programs assigned to them in the correct order.',
|
|
||||||
type: 'checkbox',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'Allow Guest Access',
|
label: 'Allow Guest Access',
|
||||||
name: 'allow_guest_access',
|
name: 'allow_guest_access',
|
||||||
@@ -132,6 +109,20 @@ const tabsStructure = computed(() => {
|
|||||||
'If enabled, users can access the course and batch lists without logging in.',
|
'If enabled, users can access the course and batch lists without logging in.',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Enable Learning Paths',
|
||||||
|
name: 'enable_learning_paths',
|
||||||
|
description:
|
||||||
|
'This will ensure students follow the assigned programs in order.',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Prevent Skipping Videos',
|
||||||
|
name: 'prevent_skipping_videos',
|
||||||
|
type: 'checkbox',
|
||||||
|
description:
|
||||||
|
'If enabled, users will no able to move forward in a video',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Send calendar invite for evaluations',
|
label: 'Send calendar invite for evaluations',
|
||||||
name: 'send_calendar_invite_for_evaluations',
|
name: 'send_calendar_invite_for_evaluations',
|
||||||
@@ -139,11 +130,34 @@ const tabsStructure = computed(() => {
|
|||||||
'If enabled, it sends google calendar invite to the student for evaluations.',
|
'If enabled, it sends google calendar invite to the student for evaluations.',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'Column Break',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Livecode URL',
|
||||||
|
name: 'livecode_url',
|
||||||
|
doctype: 'Livecode URL',
|
||||||
|
type: 'text',
|
||||||
|
description:
|
||||||
|
'https://docs.frappe.io/learning/falcon-self-hosting-guide',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Batch Confirmation Email Template',
|
||||||
|
name: 'batch_confirmation_template',
|
||||||
|
doctype: 'Email Template',
|
||||||
|
type: 'Link',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Certification Email Template',
|
||||||
|
name: 'certification_template',
|
||||||
|
doctype: 'Email Template',
|
||||||
|
type: 'Link',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Unsplash Access Key',
|
label: 'Unsplash Access Key',
|
||||||
name: 'unsplash_access_key',
|
name: 'unsplash_access_key',
|
||||||
description:
|
description:
|
||||||
'Optional. If this is set, students can pick a cover image from the unsplash library for their profile page. https://unsplash.com/documentation#getting-started.',
|
'Allows users to pick a profile cover image from Unsplash. https://unsplash.com/documentation#getting-started.',
|
||||||
type: 'password',
|
type: 'password',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -160,6 +174,12 @@ const tabsStructure = computed(() => {
|
|||||||
description:
|
description:
|
||||||
'Configure the payment gateway and other payment related settings',
|
'Configure the payment gateway and other payment related settings',
|
||||||
fields: [
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Default Currency',
|
||||||
|
name: 'default_currency',
|
||||||
|
type: 'Link',
|
||||||
|
doctype: 'Currency',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Payment Gateway',
|
label: 'Payment Gateway',
|
||||||
name: 'payment_gateway',
|
name: 'payment_gateway',
|
||||||
@@ -167,10 +187,7 @@ const tabsStructure = computed(() => {
|
|||||||
doctype: 'Payment Gateway',
|
doctype: 'Payment Gateway',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Default Currency',
|
type: 'Column Break',
|
||||||
name: 'default_currency',
|
|
||||||
type: 'Link',
|
|
||||||
doctype: 'Currency',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Apply GST for India',
|
label: 'Apply GST for India',
|
||||||
@@ -197,28 +214,55 @@ const tabsStructure = computed(() => {
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: 'Members',
|
label: 'Members',
|
||||||
description: 'Manage the members of your learning system',
|
description:
|
||||||
|
'Add new members or manage roles and permissions of existing members',
|
||||||
icon: 'UserRoundPlus',
|
icon: 'UserRoundPlus',
|
||||||
|
template: markRaw(Members),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Evaluators',
|
label: 'Evaluators',
|
||||||
description: 'Manage the evaluators of your learning system',
|
description: '',
|
||||||
icon: 'UserCheck',
|
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:
|
||||||
|
'Create badges and assign them to students to acknowledge their achievements',
|
||||||
|
icon: 'Award',
|
||||||
|
template: markRaw(Badges),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Categories',
|
label: 'Categories',
|
||||||
description: 'Manage the members of your learning system',
|
description: 'Double click to edit the category',
|
||||||
icon: 'Network',
|
icon: 'Network',
|
||||||
|
template: markRaw(Categories),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Email Templates',
|
||||||
|
description: 'Manage the email templates for your learning system',
|
||||||
|
icon: 'MailPlus',
|
||||||
|
template: markRaw(EmailTemplates),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Customise',
|
label: 'Customize',
|
||||||
hideLabel: false,
|
hideLabel: false,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: 'Branding',
|
label: 'Branding',
|
||||||
icon: 'Blocks',
|
icon: 'Blocks',
|
||||||
|
template: markRaw(BrandSettings),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
label: 'Brand Name',
|
label: 'Brand Name',
|
||||||
@@ -235,28 +279,6 @@ const tabsStructure = computed(() => {
|
|||||||
name: 'favicon',
|
name: 'favicon',
|
||||||
type: 'Upload',
|
type: 'Upload',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Footer Logo',
|
|
||||||
name: 'footer_logo',
|
|
||||||
type: 'Upload',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Address',
|
|
||||||
name: 'address',
|
|
||||||
type: 'textarea',
|
|
||||||
rows: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Footer "Powered By"',
|
|
||||||
name: 'footer_powered',
|
|
||||||
type: 'textarea',
|
|
||||||
rows: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Copyright',
|
|
||||||
name: 'copyright',
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -275,8 +297,13 @@ const tabsStructure = computed(() => {
|
|||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Certified Participants',
|
label: 'Programming Exercises',
|
||||||
name: 'certified_participants',
|
name: 'programming_exercises',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Certified Members',
|
||||||
|
name: 'certified_members',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -299,24 +326,6 @@ const tabsStructure = computed(() => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Email Templates',
|
|
||||||
icon: 'MailPlus',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
label: 'Batch Confirmation Template',
|
|
||||||
name: 'batch_confirmation_template',
|
|
||||||
doctype: 'Email Template',
|
|
||||||
type: 'Link',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Certification Template',
|
|
||||||
name: 'certification_template',
|
|
||||||
doctype: 'Email Template',
|
|
||||||
type: 'Link',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'Signup',
|
label: 'Signup',
|
||||||
icon: 'LogIn',
|
icon: 'LogIn',
|
||||||
@@ -335,6 +344,9 @@ const tabsStructure = computed(() => {
|
|||||||
description:
|
description:
|
||||||
'New users will have to be manually registered by Admins.',
|
'New users will have to be manually registered by Admins.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'Column Break',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Signup Consent HTML',
|
label: 'Signup Consent HTML',
|
||||||
name: 'custom_signup_content',
|
name: 'custom_signup_content',
|
||||||
@@ -362,12 +374,16 @@ const tabsStructure = computed(() => {
|
|||||||
type: 'textarea',
|
type: 'textarea',
|
||||||
rows: 4,
|
rows: 4,
|
||||||
description:
|
description:
|
||||||
'Keywords for search engines to find your website. Separated by commas.',
|
'Comma separated keywords for search engines to find your website.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Column Break',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Meta Image',
|
label: 'Meta Image',
|
||||||
name: 'meta_image',
|
name: 'meta_image',
|
||||||
type: 'Upload',
|
type: 'Upload',
|
||||||
|
size: 'lg',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
204
frontend/src/components/Settings/ZoomSettings.vue
Normal file
204
frontend/src/components/Settings/ZoomSettings.vue
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<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="zoomAccounts.data?.length" class="overflow-y-scroll">
|
||||||
|
<ListView
|
||||||
|
:columns="columns"
|
||||||
|
:rows="zoomAccounts.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 zoomAccounts.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>
|
||||||
|
<ZoomAccountModal
|
||||||
|
v-model="showForm"
|
||||||
|
v-model:zoomAccounts="zoomAccounts"
|
||||||
|
: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 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 zoomAccounts = createListResource({
|
||||||
|
doctype: 'LMS Zoom Settings',
|
||||||
|
fields: [
|
||||||
|
'name',
|
||||||
|
'enabled',
|
||||||
|
'member',
|
||||||
|
'member_name',
|
||||||
|
'member_image',
|
||||||
|
'account_id',
|
||||||
|
'client_id',
|
||||||
|
'client_secret',
|
||||||
|
],
|
||||||
|
cache: ['zoomAccounts'],
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchZoomAccounts()
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openForm = (accountID: string) => {
|
||||||
|
currentAccount.value = accountID
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeAccount = (selections, unselectAll) => {
|
||||||
|
call('lms.lms.api.delete_documents', {
|
||||||
|
doctype: 'LMS Zoom Settings',
|
||||||
|
documents: Array.from(selections),
|
||||||
|
})
|
||||||
|
.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(() => {
|
||||||
|
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>
|
||||||
74
frontend/src/components/Settings/types.ts
Normal file
74
frontend/src/components/Settings/types.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
export interface User {
|
||||||
|
data: {
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
user_image: string
|
||||||
|
full_name: string
|
||||||
|
user_type: ['System User', 'Website User']
|
||||||
|
username: string
|
||||||
|
is_moderator: boolean
|
||||||
|
is_system_manager: boolean
|
||||||
|
is_evaluator: boolean
|
||||||
|
is_instructor: boolean
|
||||||
|
is_fc_site: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Badge {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
enabled: boolean;
|
||||||
|
description: string;
|
||||||
|
image: string;
|
||||||
|
grant_only_once: boolean;
|
||||||
|
event: string;
|
||||||
|
reference_doctype: string;
|
||||||
|
condition: string;
|
||||||
|
user_field: string;
|
||||||
|
field_to_check: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Badges {
|
||||||
|
data: Badge[],
|
||||||
|
reload: () => void
|
||||||
|
insert: {
|
||||||
|
submit: (
|
||||||
|
data: Badge,
|
||||||
|
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||||
|
) => void
|
||||||
|
},
|
||||||
|
setValue: {
|
||||||
|
submit: (
|
||||||
|
data: Badge,
|
||||||
|
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||||
|
) => void
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BadgeAssignment {
|
||||||
|
name: string;
|
||||||
|
member: string;
|
||||||
|
member_name: string;
|
||||||
|
member_username: string;
|
||||||
|
member_image: string;
|
||||||
|
badge: string;
|
||||||
|
issued_on: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BadgeAssignments {
|
||||||
|
data: BadgeAssignment[],
|
||||||
|
reload: () => void
|
||||||
|
insert: {
|
||||||
|
submit: (
|
||||||
|
data: BadgeAssignment,
|
||||||
|
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||||
|
) => void
|
||||||
|
},
|
||||||
|
setValue: {
|
||||||
|
submit: (
|
||||||
|
data: BadgeAssignment,
|
||||||
|
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||||
|
) => void
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Tooltip, Button } from 'frappe-ui'
|
import { Tooltip } from 'frappe-ui'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import * as icons from 'lucide-vue-next'
|
import * as icons from 'lucide-vue-next'
|
||||||
|
|||||||
@@ -5,10 +5,7 @@
|
|||||||
{{ __('Upcoming Evaluations') }}
|
{{ __('Upcoming Evaluations') }}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
v-if="
|
v-if="upcoming_evals.data?.length != evaluationCourses.length"
|
||||||
!upcoming_evals.data?.length ||
|
|
||||||
upcoming_evals.length == courses.length
|
|
||||||
"
|
|
||||||
@click="openEvalModal"
|
@click="openEvalModal"
|
||||||
>
|
>
|
||||||
{{ __('Schedule Evaluation') }}
|
{{ __('Schedule Evaluation') }}
|
||||||
@@ -118,8 +115,8 @@ import {
|
|||||||
HeadsetIcon,
|
HeadsetIcon,
|
||||||
EllipsisVertical,
|
EllipsisVertical,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { inject, ref, getCurrentInstance } from 'vue'
|
import { inject, ref, getCurrentInstance, computed } from 'vue'
|
||||||
import { formatTime } from '../utils'
|
import { formatTime } from '@/utils'
|
||||||
import { Button, createResource, call } from 'frappe-ui'
|
import { Button, createResource, call } from 'frappe-ui'
|
||||||
import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
|
import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
|
||||||
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
|
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
|
||||||
@@ -163,6 +160,12 @@ const openEvalCall = (evl) => {
|
|||||||
window.open(evl.google_meet_link, '_blank')
|
window.open(evl.google_meet_link, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const evaluationCourses = computed(() => {
|
||||||
|
return props.courses.filter((course) => {
|
||||||
|
return course.evaluator != ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const cancelEvaluation = (evl) => {
|
const cancelEvaluation = (evl) => {
|
||||||
$dialog({
|
$dialog({
|
||||||
title: __('Cancel this evaluation?'),
|
title: __('Cancel this evaluation?'),
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ import { usersStore } from '@/stores/user'
|
|||||||
import { useSettings } from '@/stores/settings'
|
import { useSettings } from '@/stores/settings'
|
||||||
import { markRaw, watch, ref, onMounted, computed } from 'vue'
|
import { markRaw, watch, ref, onMounted, computed } from 'vue'
|
||||||
import { createDialog } from '@/utils/dialogs'
|
import { createDialog } from '@/utils/dialogs'
|
||||||
import SettingsModal from '@/components/Modals/Settings.vue'
|
import SettingsModal from '@/components/Settings/Settings.vue'
|
||||||
import FrappeCloudIcon from '@/components/Icons/FrappeCloudIcon.vue'
|
import FrappeCloudIcon from '@/components/Icons/FrappeCloudIcon.vue'
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="videoContainer" class="video-block relative group">
|
<div>
|
||||||
|
<div v-if="quizzes.length && !showQuiz && readOnly" class="leading-5">
|
||||||
|
{{
|
||||||
|
__('This video contains {0} {1}:').format(
|
||||||
|
quizzes.length,
|
||||||
|
quizzes.length == 1 ? 'quiz' : 'quizzes'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
|
||||||
|
<div v-for="(quiz, index) in quizzes" class="pl-3 mt-1">
|
||||||
|
<span>
|
||||||
|
{{ index + 1 }}. <span class="font-semibold"> {{ quiz.quiz }} </span>
|
||||||
|
</span>
|
||||||
|
{{ __('at {0} minutes').format(formatTimestamp(quiz.time)) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!showQuiz"
|
||||||
|
ref="videoContainer"
|
||||||
|
class="video-block relative group"
|
||||||
|
>
|
||||||
<video
|
<video
|
||||||
@timeupdate="updateTime"
|
@timeupdate="updateTime"
|
||||||
@ended="videoEnded"
|
@ended="videoEnded"
|
||||||
@@ -7,9 +27,9 @@
|
|||||||
oncontextmenu="return false"
|
oncontextmenu="return false"
|
||||||
class="rounded-md border border-gray-100 cursor-pointer"
|
class="rounded-md border border-gray-100 cursor-pointer"
|
||||||
ref="videoRef"
|
ref="videoRef"
|
||||||
>
|
:src="fileURL"
|
||||||
<source :src="fileURL" :type="type" />
|
:type="type"
|
||||||
</video>
|
></video>
|
||||||
<div
|
<div
|
||||||
v-if="!playing"
|
v-if="!playing"
|
||||||
class="absolute inset-0 flex items-center justify-center cursor-pointer"
|
class="absolute inset-0 flex items-center justify-center cursor-pointer"
|
||||||
@@ -34,7 +54,7 @@
|
|||||||
'invisible group-hover:visible': playing,
|
'invisible group-hover:visible': playing,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Button variant="ghost">
|
<Button variant="ghost" class="hover:bg-transparent">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Play
|
<Play
|
||||||
v-if="!playing"
|
v-if="!playing"
|
||||||
@@ -44,12 +64,8 @@
|
|||||||
<Pause v-else @click="pauseVideo" class="size-5 text-ink-white" />
|
<Pause v-else @click="pauseVideo" class="size-5 text-ink-white" />
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" @click="toggleMute">
|
|
||||||
<template #icon>
|
<div class="relative flex items-center w-full flex-1">
|
||||||
<Volume2 v-if="!muted" class="size-5 text-ink-white" />
|
|
||||||
<VolumeX v-else class="size-5 text-ink-white" />
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -57,24 +73,91 @@
|
|||||||
step="0.1"
|
step="0.1"
|
||||||
v-model="currentTime"
|
v-model="currentTime"
|
||||||
@input="changeCurrentTime"
|
@input="changeCurrentTime"
|
||||||
class="duration-slider w-full h-1"
|
class="duration-slider h-1"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm font-semibold">
|
<!-- QUIZ MARKERS -->
|
||||||
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
|
<div class="absolute top-0 left-0 w-full h-full pointer-events-none">
|
||||||
|
<div
|
||||||
|
v-for="(quiz, index) in quizzes"
|
||||||
|
:key="index"
|
||||||
|
:style="getQuizMarkerStyle(quiz.time)"
|
||||||
|
class="absolute top-0 h-full w-2 bg-surface-amber-3"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{{ formatSeconds(currentTime) }} / {{ formatSeconds(duration) }}
|
||||||
</span>
|
</span>
|
||||||
<Button variant="ghost" @click="toggleFullscreen">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="toggleMute"
|
||||||
|
class="hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Volume2 v-if="!muted" class="size-5 text-ink-white" />
|
||||||
|
<VolumeX v-else class="size-5 text-ink-white" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="toggleFullscreen"
|
||||||
|
class="hover:bg-transparent"
|
||||||
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Maximize class="size-5 text-ink-white" />
|
<Maximize class="size-5 text-ink-white" />
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Quiz
|
||||||
|
v-if="showQuiz"
|
||||||
|
:quizName="currentQuiz"
|
||||||
|
:inVideo="true"
|
||||||
|
:backToVideo="resumeVideo"
|
||||||
|
/>
|
||||||
|
<div v-if="!readOnly" @click="showQuizModal = true">
|
||||||
|
<Button>
|
||||||
|
{{ __('Add Quiz to Video') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<QuizInVideo
|
||||||
|
v-model="showQuizModal"
|
||||||
|
:quizzes="quizzes"
|
||||||
|
:saveQuizzes="saveQuizzes"
|
||||||
|
:duration="duration"
|
||||||
|
/>
|
||||||
|
<Dialog
|
||||||
|
v-model="showQuizLoader"
|
||||||
|
:options="{
|
||||||
|
size: 'sm',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="flex flex-col space-y-2 p-5 text-base leading-5">
|
||||||
|
<span class="font-semibold">
|
||||||
|
{{ __('Time for a Quiz') }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'Complete the upcoming quiz to continue watching the video. The quiz will open in {0} {1}.'
|
||||||
|
).format(quizLoadTimer, quizLoadTimer === 1 ? 'second' : 'seconds')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed, watch } from 'vue'
|
||||||
import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
|
import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
|
||||||
import { Button } from 'frappe-ui'
|
import { Button, Dialog } from 'frappe-ui'
|
||||||
|
import { formatSeconds, formatTimestamp } from '@/utils'
|
||||||
|
import { useSettings } from '@/stores/settings'
|
||||||
import Play from '@/components/Icons/Play.vue'
|
import Play from '@/components/Icons/Play.vue'
|
||||||
|
import QuizInVideo from '@/components/Modals/QuizInVideo.vue'
|
||||||
|
|
||||||
const videoRef = ref(null)
|
const videoRef = ref(null)
|
||||||
const videoContainer = ref(null)
|
const videoContainer = ref(null)
|
||||||
@@ -82,6 +165,13 @@ let playing = ref(false)
|
|||||||
let currentTime = ref(0)
|
let currentTime = ref(0)
|
||||||
let duration = ref(0)
|
let duration = ref(0)
|
||||||
let muted = ref(false)
|
let muted = ref(false)
|
||||||
|
const showQuizModal = ref(false)
|
||||||
|
const showQuiz = ref(false)
|
||||||
|
const showQuizLoader = ref(false)
|
||||||
|
const quizLoadTimer = ref(0)
|
||||||
|
const currentQuiz = ref(null)
|
||||||
|
const nextQuiz = ref({})
|
||||||
|
const { preventSkippingVideos } = useSettings()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
file: {
|
file: {
|
||||||
@@ -92,34 +182,94 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'video/mp4',
|
default: 'video/mp4',
|
||||||
},
|
},
|
||||||
|
readOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
quizzes: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
saveQuizzes: {
|
||||||
|
type: Function,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
updateCurrentTime()
|
||||||
|
updateNextQuiz()
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateCurrentTime = () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
videoRef.value.onloadedmetadata = () => {
|
videoRef.value.onloadedmetadata = () => {
|
||||||
duration.value = videoRef.value.duration
|
duration.value = videoRef.value.duration
|
||||||
}
|
}
|
||||||
videoRef.value.ontimeupdate = () => {
|
videoRef.value.ontimeupdate = () => {
|
||||||
currentTime.value = videoRef.value.currentTime
|
currentTime.value = videoRef.value?.currentTime || currentTime.value
|
||||||
|
if (currentTime.value >= nextQuiz.value.time) {
|
||||||
|
videoRef.value.pause()
|
||||||
|
playing.value = false
|
||||||
|
videoRef.value.onTimeupdate = null
|
||||||
|
currentQuiz.value = nextQuiz.value.quiz
|
||||||
|
quizLoadTimer.value = 7
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 0)
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(quizLoadTimer, () => {
|
||||||
|
if (quizLoadTimer.value > 0) {
|
||||||
|
showQuizLoader.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
quizLoadTimer.value -= 1
|
||||||
|
}, 1000)
|
||||||
|
} else {
|
||||||
|
showQuizLoader.value = false
|
||||||
|
showQuiz.value = true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const resumeVideo = (restart = false) => {
|
||||||
|
showQuiz.value = false
|
||||||
|
currentQuiz.value = null
|
||||||
|
updateCurrentTime()
|
||||||
|
setTimeout(() => {
|
||||||
|
videoRef.value.currentTime = restart ? 0 : currentTime.value
|
||||||
|
videoRef.value.play()
|
||||||
|
playing.value = true
|
||||||
|
updateNextQuiz()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateNextQuiz = () => {
|
||||||
|
if (!props.quizzes.length) return
|
||||||
|
|
||||||
|
props.quizzes.forEach((quiz) => {
|
||||||
|
if (typeof quiz.time == 'string' && quiz.time.includes(':')) {
|
||||||
|
let time = quiz.time.split(':')
|
||||||
|
let timeInSeconds = parseInt(time[0]) * 60 + parseInt(time[1])
|
||||||
|
quiz.time = timeInSeconds
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
props.quizzes.sort((a, b) => a.time - b.time)
|
||||||
|
|
||||||
|
const nextQuizIndex = props.quizzes.findIndex(
|
||||||
|
(quiz) => quiz.time > currentTime.value
|
||||||
|
)
|
||||||
|
if (nextQuizIndex !== -1) {
|
||||||
|
nextQuiz.value = props.quizzes[nextQuizIndex]
|
||||||
|
} else {
|
||||||
|
nextQuiz.value = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fileURL = computed(() => {
|
const fileURL = computed(() => {
|
||||||
if (isYoutube) {
|
|
||||||
let url = props.file
|
|
||||||
if (url.includes('watch?v=')) {
|
|
||||||
url = url.replace('watch?v=', 'embed/')
|
|
||||||
}
|
|
||||||
return `${url}?autoplay=0&controls=0&disablekb=1&playsinline=1&cc_load_policy=1&cc_lang_pref=auto`
|
|
||||||
}
|
|
||||||
return props.file
|
return props.file
|
||||||
})
|
})
|
||||||
|
|
||||||
const isYoutube = computed(() => {
|
|
||||||
return props.type == 'video/youtube'
|
|
||||||
})
|
|
||||||
|
|
||||||
const playVideo = () => {
|
const playVideo = () => {
|
||||||
videoRef.value.play()
|
videoRef.value.play()
|
||||||
playing.value = true
|
playing.value = true
|
||||||
@@ -148,13 +298,13 @@ const toggleMute = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const changeCurrentTime = () => {
|
const changeCurrentTime = () => {
|
||||||
|
if (
|
||||||
|
preventSkippingVideos.data &&
|
||||||
|
currentTime.value > videoRef.value.currentTime
|
||||||
|
)
|
||||||
|
return
|
||||||
videoRef.value.currentTime = currentTime.value
|
videoRef.value.currentTime = currentTime.value
|
||||||
}
|
updateNextQuiz()
|
||||||
|
|
||||||
const formatTime = (time) => {
|
|
||||||
const minutes = Math.floor(time / 60)
|
|
||||||
const seconds = Math.floor(time % 60)
|
|
||||||
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleFullscreen = () => {
|
const toggleFullscreen = () => {
|
||||||
@@ -164,6 +314,13 @@ const toggleFullscreen = () => {
|
|||||||
videoContainer.value.requestFullscreen()
|
videoContainer.value.requestFullscreen()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getQuizMarkerStyle = (time) => {
|
||||||
|
const percentage = ((time - 5) / Math.ceil(duration.value)) * 100
|
||||||
|
return {
|
||||||
|
left: `${percentage}%`,
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -183,11 +340,10 @@ iframe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.duration-slider {
|
.duration-slider {
|
||||||
flex: 1;
|
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background-color: theme('colors.gray.100');
|
background-color: theme('colors.gray.600');
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,20 +351,20 @@ iframe {
|
|||||||
width: 2px;
|
width: 2px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
background-color: theme('colors.gray.500');
|
background-color: theme('colors.white');
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (-webkit-min-device-pixel-ratio: 0) {
|
@media screen and (-webkit-min-device-pixel-ratio: 0) {
|
||||||
input[type='range'] {
|
input[type='range'] {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 150px;
|
width: 100%;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='range']::-webkit-slider-thumb {
|
input[type='range']::-webkit-slider-thumb {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: -500px 0 0 500px theme('colors.gray.600');
|
box-shadow: -500px 0 0 500px theme('colors.white');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
11
frontend/src/global.d.ts
vendored
Normal file
11
frontend/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export {}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
function __(text: string): string
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'vue' {
|
||||||
|
interface ComponentCustomProperties {
|
||||||
|
__: (text: string) => string
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
@import './assets/Inter/inter.css';
|
@import './assets/Inter/inter.css';
|
||||||
@import 'frappe-ui/src/style.css';
|
@import 'frappe-ui/src/style.css';
|
||||||
|
@import './styles/codemirror.css';
|
||||||
@@ -145,7 +145,6 @@ const submissions = createListResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// watch changes in assignmentID, member, and status and if changes in any then reload submissions. Also update the url query params for the same
|
|
||||||
watch([assignmentID, member, status], () => {
|
watch([assignmentID, member, status], () => {
|
||||||
router.push({
|
router.push({
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
@@ -16,20 +16,17 @@
|
|||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus class="w-4 h-4" />
|
<Plus class="w-4 h-4" />
|
||||||
</template>
|
</template>
|
||||||
{{ __('New') }}
|
{{ __('Create') }}
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="md:w-3/4 md:mx-auto py-5 mx-5">
|
<div class="md:w-3/4 md:mx-auto py-5 mx-5">
|
||||||
<div class="flex items-center justify-between mb-5">
|
<div class="flex items-center justify-between mb-5">
|
||||||
<div
|
<div v-if="assignmentCount" class="text-lg font-semibold text-ink-gray-9">
|
||||||
v-if="assignmentCount"
|
|
||||||
class="text-xl font-semibold text-ink-gray-7 mb-4"
|
|
||||||
>
|
|
||||||
{{ __('{0} Assignments').format(assignmentCount) }}
|
{{ __('{0} Assignments').format(assignmentCount) }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="assignments.data?.length || assigmentCount > 0"
|
v-if="assignments.data?.length || assignmentCount > 0"
|
||||||
class="grid grid-cols-2 gap-5"
|
class="grid grid-cols-2 gap-5"
|
||||||
>
|
>
|
||||||
<FormControl
|
<FormControl
|
||||||
|
|||||||
@@ -70,7 +70,10 @@
|
|||||||
<BatchStudents :batch="batch" />
|
<BatchStudents :batch="batch" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="tab.label == 'Classes'">
|
<div v-else-if="tab.label == 'Classes'">
|
||||||
<LiveClass :batch="batch.data.name" />
|
<LiveClass
|
||||||
|
:batch="batch.data.name"
|
||||||
|
:zoomAccount="batch.data.zoom_account"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="tab.label == 'Assessments'">
|
<div v-else-if="tab.label == 'Assessments'">
|
||||||
<Assessments :batch="batch.data.name" />
|
<Assessments :batch="batch.data.name" />
|
||||||
@@ -88,16 +91,14 @@
|
|||||||
:scrollToBottom="false"
|
:scrollToBottom="false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="tab.label == 'Feedback'">
|
|
||||||
<BatchFeedback :batch="batch.data.name" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
<div class="text-ink-gray-7 font-semibold mb-4">
|
<div class="mb-10">
|
||||||
{{ __('About this batch') }}:
|
<div class="text-ink-gray-7 font-semibold mb-2">
|
||||||
|
{{ __('About this batch') }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-html="batch.data.description"
|
v-html="batch.data.description"
|
||||||
@@ -123,7 +124,7 @@
|
|||||||
:endDate="batch.data.end_date"
|
:endDate="batch.data.end_date"
|
||||||
class="mb-3"
|
class="mb-3"
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center mb-4 text-ink-gray-7">
|
<div class="flex items-center mb-3 text-ink-gray-7">
|
||||||
<Clock class="h-4 w-4 stroke-1.5 mr-2" />
|
<Clock class="h-4 w-4 stroke-1.5 mr-2" />
|
||||||
<span>
|
<span>
|
||||||
{{ formatTime(batch.data.start_time) }} -
|
{{ formatTime(batch.data.start_time) }} -
|
||||||
@@ -132,7 +133,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="batch.data.timezone"
|
v-if="batch.data.timezone"
|
||||||
class="flex items-center mb-4 text-ink-gray-7"
|
class="flex items-center mb-3 text-ink-gray-7"
|
||||||
>
|
>
|
||||||
<Globe class="h-4 w-4 stroke-1.5 mr-2" />
|
<Globe class="h-4 w-4 stroke-1.5 mr-2" />
|
||||||
<span>
|
<span>
|
||||||
@@ -140,6 +141,13 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<AnnouncementModal
|
||||||
v-model="showAnnouncementModal"
|
v-model="showAnnouncementModal"
|
||||||
:batch="batch.data.name"
|
:batch="batch.data.name"
|
||||||
@@ -234,6 +242,7 @@ import Discussions from '@/components/Discussions.vue'
|
|||||||
import DateRange from '@/components/Common/DateRange.vue'
|
import DateRange from '@/components/Common/DateRange.vue'
|
||||||
import BulkCertificates from '@/components/Modals/BulkCertificates.vue'
|
import BulkCertificates from '@/components/Modals/BulkCertificates.vue'
|
||||||
import BatchFeedback from '@/components/BatchFeedback.vue'
|
import BatchFeedback from '@/components/BatchFeedback.vue'
|
||||||
|
import dayjs from 'dayjs/esm'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const showAnnouncementModal = ref(false)
|
const showAnnouncementModal = ref(false)
|
||||||
@@ -277,11 +286,6 @@ const tabs = computed(() => {
|
|||||||
label: 'Discussions',
|
label: 'Discussions',
|
||||||
icon: MessageCircle,
|
icon: MessageCircle,
|
||||||
})
|
})
|
||||||
|
|
||||||
batchTabs.push({
|
|
||||||
label: 'Feedback',
|
|
||||||
icon: ClipboardPen,
|
|
||||||
})
|
|
||||||
return batchTabs
|
return batchTabs
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -37,14 +37,7 @@
|
|||||||
<BatchOverlay :batch="batch" />
|
<BatchOverlay :batch="batch" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="grid lg:grid-cols-[60%,20%] gap-4 lg:gap-20 mt-10">
|
<BatchOverlay :batch="batch" class="md:hidden mt-5" />
|
||||||
<div class="order-2 lg:order-none">
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div class="order-1 lg:order-none">
|
|
||||||
<BatchOverlay :batch="batch" />
|
|
||||||
</div>
|
|
||||||
</div> -->
|
|
||||||
<div v-if="batch.data.courses.length">
|
<div v-if="batch.data.courses.length">
|
||||||
<div class="flex items-center mt-10">
|
<div class="flex items-center mt-10">
|
||||||
<div class="text-2xl font-semibold">
|
<div class="text-2xl font-semibold">
|
||||||
|
|||||||
@@ -4,9 +4,16 @@
|
|||||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||||
>
|
>
|
||||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||||
|
<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()">
|
<Button variant="solid" @click="saveBatch()">
|
||||||
{{ __('Save') }}
|
{{ __('Save') }}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="py-5">
|
<div class="py-5">
|
||||||
<div class="px-20 pb-5 space-y-5 border-b mb-5">
|
<div class="px-20 pb-5 space-y-5 border-b mb-5">
|
||||||
@@ -23,10 +30,10 @@
|
|||||||
/>
|
/>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
v-model="instructors"
|
v-model="instructors"
|
||||||
doctype="User"
|
doctype="Course Evaluator"
|
||||||
:label="__('Instructors')"
|
:label="__('Instructors')"
|
||||||
:required="true"
|
:required="true"
|
||||||
:onCreate="(close) => openSettings('Members', close)"
|
:onCreate="(close) => openSettings('Evaluators', close)"
|
||||||
:filters="{ ignore_user_type: 1 }"
|
:filters="{ ignore_user_type: 1 }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,6 +160,21 @@
|
|||||||
doctype="Email Template"
|
doctype="Email Template"
|
||||||
:label="__('Email Template')"
|
:label="__('Email Template')"
|
||||||
v-model="batch.confirmation_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>
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
@@ -194,7 +216,10 @@
|
|||||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="border rounded-md w-fit py-5 px-20">
|
<div
|
||||||
|
class="border rounded-md w-fit py-5 px-20 cursor-pointer"
|
||||||
|
@click="openFileSelector"
|
||||||
|
>
|
||||||
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
@@ -258,16 +283,38 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="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">
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
onMounted,
|
getCurrentInstance,
|
||||||
inject,
|
inject,
|
||||||
reactive,
|
onMounted,
|
||||||
onBeforeUnmount,
|
onBeforeUnmount,
|
||||||
|
reactive,
|
||||||
ref,
|
ref,
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import {
|
import {
|
||||||
@@ -279,20 +326,30 @@ import {
|
|||||||
createResource,
|
createResource,
|
||||||
usePageMeta,
|
usePageMeta,
|
||||||
toast,
|
toast,
|
||||||
|
call,
|
||||||
|
Toast,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Image } from 'lucide-vue-next'
|
import { Image, Trash2 } from 'lucide-vue-next'
|
||||||
import { capture } from '@/telemetry'
|
import { capture } from '@/telemetry'
|
||||||
import { useOnboarding } from 'frappe-ui/frappe'
|
import { useOnboarding } from 'frappe-ui/frappe'
|
||||||
import { sessionStore } from '../stores/session'
|
import { sessionStore } from '../stores/session'
|
||||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { openSettings } from '@/utils'
|
import {
|
||||||
|
openSettings,
|
||||||
|
getMetaInfo,
|
||||||
|
updateMetaInfo,
|
||||||
|
validateFile,
|
||||||
|
} from '@/utils'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const { brand } = sessionStore()
|
const { brand } = sessionStore()
|
||||||
const { updateOnboardingStep } = useOnboarding('learning')
|
const { updateOnboardingStep } = useOnboarding('learning')
|
||||||
|
const instructors = ref([])
|
||||||
|
const app = getCurrentInstance()
|
||||||
|
const { $dialog } = app.appContext.config.globalProperties
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batchName: {
|
batchName: {
|
||||||
@@ -322,20 +379,29 @@ const batch = reactive({
|
|||||||
paid_batch: false,
|
paid_batch: false,
|
||||||
currency: '',
|
currency: '',
|
||||||
amount: 0,
|
amount: 0,
|
||||||
|
zoom_account: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const instructors = ref([])
|
const meta = reactive({
|
||||||
|
description: '',
|
||||||
|
keywords: '',
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!user.data) window.location.href = '/login'
|
if (!user.data) window.location.href = '/login'
|
||||||
if (props.batchName != 'new') {
|
if (props.batchName != 'new') {
|
||||||
batchDetail.reload()
|
fetchBatchInfo()
|
||||||
} else {
|
} else {
|
||||||
capture('batch_form_opened')
|
capture('batch_form_opened')
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', keyboardShortcut)
|
window.addEventListener('keydown', keyboardShortcut)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const fetchBatchInfo = () => {
|
||||||
|
batchDetail.reload()
|
||||||
|
getMetaInfo('batches', props.batchName, meta)
|
||||||
|
}
|
||||||
|
|
||||||
const keyboardShortcut = (e) => {
|
const keyboardShortcut = (e) => {
|
||||||
if (
|
if (
|
||||||
e.key === 's' &&
|
e.key === 's' &&
|
||||||
@@ -449,7 +515,7 @@ const createNewBatch = () => {
|
|||||||
localStorage.setItem('firstBatch', data.name)
|
localStorage.setItem('firstBatch', data.name)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
updateMetaInfo('batches', data.name, meta)
|
||||||
capture('batch_created')
|
capture('batch_created')
|
||||||
router.push({
|
router.push({
|
||||||
name: 'BatchDetail',
|
name: 'BatchDetail',
|
||||||
@@ -470,6 +536,7 @@ const editBatchDetails = () => {
|
|||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
|
updateMetaInfo('batches', data.name, meta)
|
||||||
router.push({
|
router.push({
|
||||||
name: 'BatchDetail',
|
name: 'BatchDetail',
|
||||||
params: {
|
params: {
|
||||||
@@ -484,6 +551,38 @@ const editBatchDetails = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 saveImage = (file) => {
|
const saveImage = (file) => {
|
||||||
batch.image = file
|
batch.image = file
|
||||||
}
|
}
|
||||||
@@ -492,13 +591,6 @@ const removeImage = () => {
|
|||||||
batch.image = null
|
batch.image = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateFile = (file) => {
|
|
||||||
let extension = file.name.split('.').pop().toLowerCase()
|
|
||||||
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
|
||||||
return 'Only image file is allowed.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let crumbs = [
|
let crumbs = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,20 +14,18 @@
|
|||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus class="h-4 w-4 stroke-1.5" />
|
<Plus class="h-4 w-4 stroke-1.5" />
|
||||||
</template>
|
</template>
|
||||||
{{ __('New') }}
|
{{ __('Create') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
</header>
|
</header>
|
||||||
<div class="p-5 pb-10">
|
<div class="p-5 pb-10">
|
||||||
<div
|
<div
|
||||||
v-if="batchCount"
|
|
||||||
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
|
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
|
||||||
>
|
>
|
||||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||||
{{ __('All Batches') }}
|
{{ __('All Batches') }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="batches.data?.length || batchCount"
|
|
||||||
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
|
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
|
||||||
>
|
>
|
||||||
<TabButtons
|
<TabButtons
|
||||||
@@ -115,12 +113,10 @@ const is_student = computed(() => user.data?.is_student)
|
|||||||
const currentTab = ref(is_student.value ? 'All' : 'Upcoming')
|
const currentTab = ref(is_student.value ? 'All' : 'Upcoming')
|
||||||
const orderBy = ref('start_date')
|
const orderBy = ref('start_date')
|
||||||
const readOnlyMode = window.read_only_mode
|
const readOnlyMode = window.read_only_mode
|
||||||
const batchCount = ref(0)
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setFiltersFromQuery()
|
setFiltersFromQuery()
|
||||||
updateBatches()
|
updateBatches()
|
||||||
getBatchCount()
|
|
||||||
categories.value = [
|
categories.value = [
|
||||||
{
|
{
|
||||||
label: '',
|
label: '',
|
||||||
@@ -298,14 +294,6 @@ const canCreateBatch = () => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const getBatchCount = () => {
|
|
||||||
call('frappe.client.get_count', {
|
|
||||||
doctype: 'LMS Batch',
|
|
||||||
}).then((data) => {
|
|
||||||
batchCount.value = data
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const breadcrumbs = computed(() => [
|
const breadcrumbs = computed(() => [
|
||||||
{
|
{
|
||||||
label: __('Batches'),
|
label: __('Batches'),
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
/>
|
/>
|
||||||
<FormControl :label="__('City')" v-model="billingDetails.city" />
|
<FormControl :label="__('City')" v-model="billingDetails.city" />
|
||||||
<FormControl
|
<FormControl
|
||||||
:label="__('State')"
|
:label="__('State/Province')"
|
||||||
v-model="billingDetails.state"
|
v-model="billingDetails.state"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -303,6 +303,7 @@ const validateAddress = () => {
|
|||||||
'Gujarat',
|
'Gujarat',
|
||||||
'Haryana',
|
'Haryana',
|
||||||
'Himachal Pradesh',
|
'Himachal Pradesh',
|
||||||
|
'Jammu and Kashmir',
|
||||||
'Jharkhand',
|
'Jharkhand',
|
||||||
'Karnataka',
|
'Karnataka',
|
||||||
'Kerala',
|
'Kerala',
|
||||||
|
|||||||
@@ -12,10 +12,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
</header>
|
</header>
|
||||||
<div
|
<div class="mx-auto w-full max-w-4xl pt-6 pb-10">
|
||||||
v-if="participants.data?.length"
|
|
||||||
class="mx-auto w-full max-w-4xl pt-6 pb-10"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col md:flex-row justify-between mb-4 px-3">
|
<div class="flex flex-col md:flex-row justify-between mb-4 px-3">
|
||||||
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
|
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
|
||||||
{{ memberCount }} {{ __('certified members') }}
|
{{ memberCount }} {{ __('certified members') }}
|
||||||
@@ -41,7 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y">
|
<div v-if="participants.data?.length" class="divide-y">
|
||||||
<template v-for="participant in participants.data">
|
<template v-for="participant in participants.data">
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
@@ -92,6 +89,7 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<EmptyState v-else type="Certified Members" />
|
||||||
<div
|
<div
|
||||||
v-if="!participants.list.loading && participants.hasNextPage"
|
v-if="!participants.list.loading && participants.hasNextPage"
|
||||||
class="flex justify-center mt-5"
|
class="flex justify-center mt-5"
|
||||||
@@ -101,7 +99,6 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<EmptyState v-else type="Certified Members" />
|
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
@@ -127,22 +124,25 @@ const memberCount = ref(0)
|
|||||||
const dayjs = inject('$dayjs')
|
const dayjs = inject('$dayjs')
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
getMemberCount()
|
||||||
updateParticipants()
|
updateParticipants()
|
||||||
})
|
})
|
||||||
|
|
||||||
const participants = createListResource({
|
const participants = createListResource({
|
||||||
doctype: 'LMS Certificate',
|
doctype: 'LMS Certificate',
|
||||||
url: 'lms.lms.api.get_certified_participants',
|
url: 'lms.lms.api.get_certified_participants',
|
||||||
cache: ['certified_participants'],
|
|
||||||
start: 0,
|
start: 0,
|
||||||
pageLength: 30,
|
cache: ['certified_participants'],
|
||||||
|
pageLength: 100,
|
||||||
})
|
})
|
||||||
|
|
||||||
const count = call('lms.lms.api.get_count_of_certified_members').then(
|
const getMemberCount = () => {
|
||||||
(data) => {
|
call('lms.lms.api.get_count_of_certified_members', {
|
||||||
|
filters: filters.value,
|
||||||
|
}).then((data) => {
|
||||||
memberCount.value = data
|
memberCount.value = data
|
||||||
|
})
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
const categories = createListResource({
|
const categories = createListResource({
|
||||||
doctype: 'LMS Certificate',
|
doctype: 'LMS Certificate',
|
||||||
@@ -157,6 +157,7 @@ const categories = createListResource({
|
|||||||
|
|
||||||
const updateParticipants = () => {
|
const updateParticipants = () => {
|
||||||
updateFilters()
|
updateFilters()
|
||||||
|
getMemberCount()
|
||||||
participants.update({
|
participants.update({
|
||||||
filters: filters.value,
|
filters: filters.value,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||||
</header>
|
</header>
|
||||||
<div class="m-5">
|
<div class="m-5">
|
||||||
<div class="flex justify-between w-full">
|
<div class="flex justify-between w-full space-x-5">
|
||||||
<div class="md:w-2/3">
|
<div class="md:w-2/3">
|
||||||
<div class="text-3xl font-semibold text-ink-gray-9">
|
<div class="text-3xl font-semibold text-ink-gray-9">
|
||||||
{{ course.data.title }}
|
{{ course.data.title }}
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
:text="__('Average Rating')"
|
:text="__('Average Rating')"
|
||||||
class="flex items-center"
|
class="flex items-center"
|
||||||
>
|
>
|
||||||
<Star class="h-5 w-5 text-gray-100 fill-orange-500" />
|
<Star class="size-4 text-transparent fill-yellow-500" />
|
||||||
<span class="ml-1 text-ink-gray-7">
|
<span class="ml-1 text-ink-gray-7">
|
||||||
{{ course.data.rating }}
|
{{ course.data.rating }}
|
||||||
</span>
|
</span>
|
||||||
@@ -66,7 +66,9 @@
|
|||||||
{{ tag }}
|
{{ tag }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<CourseCardOverlay :course="course" class="md:hidden mb-4" />
|
<div class="md:hidden mb-4">
|
||||||
|
<CourseCardOverlay :course="course" />
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-html="course.data.description"
|
v-html="course.data.description"
|
||||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-10"
|
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"
|
||||||
@@ -76,6 +78,7 @@
|
|||||||
:title="__('Course Outline')"
|
:title="__('Course Outline')"
|
||||||
:courseName="course.data.name"
|
:courseName="course.data.name"
|
||||||
:showOutline="true"
|
:showOutline="true"
|
||||||
|
:getProgress="course.data.membership ? true : false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CourseReviews
|
<CourseReviews
|
||||||
@@ -88,6 +91,7 @@
|
|||||||
<CourseCardOverlay :course="course" />
|
<CourseCardOverlay :course="course" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<RelatedCourses :courseName="course.data.name" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -99,7 +103,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
usePageMeta,
|
usePageMeta,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { computed } from 'vue'
|
import { computed, watch } from 'vue'
|
||||||
import { Users, Star } from 'lucide-vue-next'
|
import { Users, Star } from 'lucide-vue-next'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import CourseCardOverlay from '@/components/CourseCardOverlay.vue'
|
import CourseCardOverlay from '@/components/CourseCardOverlay.vue'
|
||||||
@@ -107,6 +111,7 @@ import CourseOutline from '@/components/CourseOutline.vue'
|
|||||||
import CourseReviews from '@/components/CourseReviews.vue'
|
import CourseReviews from '@/components/CourseReviews.vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
|
import RelatedCourses from '@/components/RelatedCourses.vue'
|
||||||
|
|
||||||
const { brand } = sessionStore()
|
const { brand } = sessionStore()
|
||||||
|
|
||||||
@@ -120,12 +125,21 @@ const props = defineProps({
|
|||||||
const course = createResource({
|
const course = createResource({
|
||||||
url: 'lms.lms.utils.get_course_details',
|
url: 'lms.lms.utils.get_course_details',
|
||||||
cache: ['course', props.courseName],
|
cache: ['course', props.courseName],
|
||||||
params: {
|
makeParams() {
|
||||||
|
return {
|
||||||
course: props.courseName,
|
course: props.courseName,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.courseName,
|
||||||
|
() => {
|
||||||
|
course.reload()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
||||||
items.push({
|
items.push({
|
||||||
|
|||||||
@@ -47,14 +47,22 @@
|
|||||||
:required="true"
|
:required="true"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1.5 text-xs text-ink-gray-5">
|
<div class="text-xs text-ink-gray-5">
|
||||||
{{ __('Tags') }}
|
{{ __('Tags') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<FormControl
|
||||||
|
v-model="newTag"
|
||||||
|
:placeholder="__('Add a keyword and then press enter')"
|
||||||
|
:class="['w-full', 'flex-1', 'my-1']"
|
||||||
|
@keyup.enter="updateTags()"
|
||||||
|
id="tags"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center flex-wrap gap-2">
|
||||||
<div
|
<div
|
||||||
v-if="course.tags"
|
v-if="course.tags"
|
||||||
v-for="tag in course.tags?.split(', ')"
|
v-for="tag in course.tags?.split(', ')"
|
||||||
class="flex items-center bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md mr-2"
|
class="flex items-center bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md"
|
||||||
>
|
>
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
<X
|
<X
|
||||||
@@ -62,33 +70,14 @@
|
|||||||
@click="removeTag(tag)"
|
@click="removeTag(tag)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<FormControl
|
</div>
|
||||||
v-model="newTag"
|
|
||||||
:placeholder="__('Add a keyword and then press enter')"
|
|
||||||
class="w-full"
|
|
||||||
@keyup.enter="updateTags()"
|
|
||||||
id="tags"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-5">
|
<div class="grid grid-cols-2 gap-5">
|
||||||
<FormControl
|
|
||||||
v-model="course.short_introduction"
|
|
||||||
type="textarea"
|
|
||||||
:rows="4"
|
|
||||||
:label="__('Short Introduction')"
|
|
||||||
:placeholder="
|
|
||||||
__(
|
|
||||||
'A one line introduction to the course that appears on the course card'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
:required="true"
|
|
||||||
/>
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="text-xs text-ink-gray-5 mb-2">
|
<div class="text-xs text-ink-gray-5 mb-2">
|
||||||
{{ __('Course Image') }}
|
{{ __('Course Image') }}
|
||||||
<span class="text-ink-red-3">*</span>
|
|
||||||
</div>
|
</div>
|
||||||
<FileUploader
|
<FileUploader
|
||||||
v-if="!course.course_image"
|
v-if="!course.course_image"
|
||||||
@@ -100,7 +89,10 @@
|
|||||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="border rounded-md w-fit py-5 px-20">
|
<div
|
||||||
|
class="border rounded-md w-fit py-5 px-20 cursor-pointer"
|
||||||
|
@click="openFileSelector"
|
||||||
|
>
|
||||||
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
@@ -135,6 +127,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ColorSwatches
|
||||||
|
v-model="course.card_gradient"
|
||||||
|
:label="__('Color')"
|
||||||
|
:description="__('Choose a color for the course card')"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -176,6 +175,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-10 pb-5 mb-5 space-y-5 border-b">
|
<div class="px-10 pb-5 mb-5 space-y-5 border-b">
|
||||||
|
<div class="text-lg font-semibold">
|
||||||
|
{{ __('About the Course') }}
|
||||||
|
</div>
|
||||||
|
<FormControl
|
||||||
|
v-model="course.short_introduction"
|
||||||
|
type="textarea"
|
||||||
|
:rows="5"
|
||||||
|
:label="__('Short Introduction')"
|
||||||
|
:placeholder="
|
||||||
|
__(
|
||||||
|
'A one line introduction to the course that appears on the course card'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
<div class="">
|
<div class="">
|
||||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||||
{{ __('Course Description') }}
|
{{ __('Course Description') }}
|
||||||
@@ -199,9 +213,24 @@
|
|||||||
)
|
)
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<MultiSelect
|
||||||
|
v-model="related_courses"
|
||||||
|
doctype="LMS Course"
|
||||||
|
:label="__('Related Courses')"
|
||||||
|
:filters="{ name: ['!=', courseResource.data?.name] }"
|
||||||
|
:onCreate="
|
||||||
|
(close) => {
|
||||||
|
router.push({
|
||||||
|
name: 'CourseForm',
|
||||||
|
params: { courseName: 'new' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-10 pb-5 space-y-5">
|
<div class="px-10 pb-5 space-y-5 border-b">
|
||||||
<div class="text-lg font-semibold mt-5">
|
<div class="text-lg font-semibold mt-5">
|
||||||
{{ __('Pricing and Certification') }}
|
{{ __('Pricing and Certification') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -248,6 +277,27 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="px-10 pb-5 space-y-5">
|
||||||
|
<div class="text-lg font-semibold mt-5">
|
||||||
|
{{ __('Meta Tags') }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-l">
|
<div class="border-l">
|
||||||
@@ -264,6 +314,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
|
call,
|
||||||
TextEditor,
|
TextEditor,
|
||||||
Button,
|
Button,
|
||||||
createResource,
|
createResource,
|
||||||
@@ -284,19 +335,26 @@ import {
|
|||||||
} from 'vue'
|
} from 'vue'
|
||||||
import { Image, Trash2, X } from 'lucide-vue-next'
|
import { Image, Trash2, X } from 'lucide-vue-next'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { capture } from '@/telemetry'
|
import { capture, startRecording, stopRecording } from '@/telemetry'
|
||||||
import { useOnboarding } from 'frappe-ui/frappe'
|
import { useOnboarding } from 'frappe-ui/frappe'
|
||||||
import { sessionStore } from '../stores/session'
|
import { sessionStore } from '../stores/session'
|
||||||
import { openSettings } from '@/utils'
|
import {
|
||||||
|
openSettings,
|
||||||
|
getMetaInfo,
|
||||||
|
updateMetaInfo,
|
||||||
|
validateFile,
|
||||||
|
} from '@/utils'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import CourseOutline from '@/components/CourseOutline.vue'
|
import CourseOutline from '@/components/CourseOutline.vue'
|
||||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||||
|
import ColorSwatches from '@/components/Controls/ColorSwatches.vue'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const newTag = ref('')
|
const newTag = ref('')
|
||||||
const { brand } = sessionStore()
|
const { brand } = sessionStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const instructors = ref([])
|
const instructors = ref([])
|
||||||
|
const related_courses = ref([])
|
||||||
const app = getCurrentInstance()
|
const app = getCurrentInstance()
|
||||||
const { updateOnboardingStep } = useOnboarding('learning')
|
const { updateOnboardingStep } = useOnboarding('learning')
|
||||||
const { $dialog } = app.appContext.config.globalProperties
|
const { $dialog } = app.appContext.config.globalProperties
|
||||||
@@ -313,6 +371,7 @@ const course = reactive({
|
|||||||
description: '',
|
description: '',
|
||||||
video_link: '',
|
video_link: '',
|
||||||
course_image: null,
|
course_image: null,
|
||||||
|
card_gradient: '',
|
||||||
tags: '',
|
tags: '',
|
||||||
category: '',
|
category: '',
|
||||||
published: false,
|
published: false,
|
||||||
@@ -328,19 +387,30 @@ const course = reactive({
|
|||||||
evaluator: '',
|
evaluator: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const meta = reactive({
|
||||||
|
description: '',
|
||||||
|
keywords: '',
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
||||||
router.push({ name: 'Courses' })
|
router.push({ name: 'Courses' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.courseName !== 'new') {
|
if (props.courseName !== 'new') {
|
||||||
courseResource.reload()
|
fetchCourseInfo()
|
||||||
} else {
|
} else {
|
||||||
capture('course_form_opened')
|
capture('course_form_opened')
|
||||||
|
startRecording()
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', keyboardShortcut)
|
window.addEventListener('keydown', keyboardShortcut)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const fetchCourseInfo = () => {
|
||||||
|
courseResource.reload()
|
||||||
|
getMetaInfo('courses', props.courseName, meta)
|
||||||
|
}
|
||||||
|
|
||||||
const keyboardShortcut = (e) => {
|
const keyboardShortcut = (e) => {
|
||||||
if (
|
if (
|
||||||
e.key === 's' &&
|
e.key === 's' &&
|
||||||
@@ -354,6 +424,7 @@ const keyboardShortcut = (e) => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('keydown', keyboardShortcut)
|
window.removeEventListener('keydown', keyboardShortcut)
|
||||||
|
stopRecording()
|
||||||
})
|
})
|
||||||
|
|
||||||
const courseCreationResource = createResource({
|
const courseCreationResource = createResource({
|
||||||
@@ -366,6 +437,9 @@ const courseCreationResource = createResource({
|
|||||||
instructors: instructors.value.map((instructor) => ({
|
instructors: instructors.value.map((instructor) => ({
|
||||||
instructor: instructor,
|
instructor: instructor,
|
||||||
})),
|
})),
|
||||||
|
related_courses: related_courses.value.map((course) => ({
|
||||||
|
course: course,
|
||||||
|
})),
|
||||||
...values,
|
...values,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -384,6 +458,9 @@ const courseEditResource = createResource({
|
|||||||
instructors: instructors.value.map((instructor) => ({
|
instructors: instructors.value.map((instructor) => ({
|
||||||
instructor: instructor,
|
instructor: instructor,
|
||||||
})),
|
})),
|
||||||
|
related_courses: related_courses.value.map((course) => ({
|
||||||
|
course: course,
|
||||||
|
})),
|
||||||
...course,
|
...course,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -406,6 +483,11 @@ const courseResource = createResource({
|
|||||||
data.instructors.forEach((instructor) => {
|
data.instructors.forEach((instructor) => {
|
||||||
instructors.value.push(instructor.instructor)
|
instructors.value.push(instructor.instructor)
|
||||||
})
|
})
|
||||||
|
} else if (key == 'related_courses') {
|
||||||
|
related_courses.value = []
|
||||||
|
data.related_courses.forEach((course) => {
|
||||||
|
related_courses.value.push(course.course)
|
||||||
|
})
|
||||||
} else if (Object.hasOwn(course, key)) course[key] = data[key]
|
} else if (Object.hasOwn(course, key)) course[key] = data[key]
|
||||||
})
|
})
|
||||||
let checkboxes = [
|
let checkboxes = [
|
||||||
@@ -442,22 +524,16 @@ const imageResource = createResource({
|
|||||||
|
|
||||||
const submitCourse = () => {
|
const submitCourse = () => {
|
||||||
if (courseResource.data) {
|
if (courseResource.data) {
|
||||||
courseEditResource.submit(
|
editCourse()
|
||||||
{
|
|
||||||
course: courseResource.data.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess() {
|
|
||||||
toast.success(__('Course updated successfully'))
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
toast.error(err.messages?.[0] || err)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
|
createCourse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCourse = () => {
|
||||||
courseCreationResource.submit(course, {
|
courseCreationResource.submit(course, {
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
|
updateMetaInfo('courses', data.name, meta)
|
||||||
if (user.data?.is_system_manager) {
|
if (user.data?.is_system_manager) {
|
||||||
updateOnboardingStep('create_first_course', true, false, () => {
|
updateOnboardingStep('create_first_course', true, false, () => {
|
||||||
localStorage.setItem('firstCourse', data.name)
|
localStorage.setItem('firstCourse', data.name)
|
||||||
@@ -476,6 +552,22 @@ const submitCourse = () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const editCourse = () => {
|
||||||
|
courseEditResource.submit(
|
||||||
|
{
|
||||||
|
course: courseResource.data.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
updateMetaInfo('courses', props.courseName, meta)
|
||||||
|
toast.success(__('Course updated successfully'))
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
toast.error(err.messages?.[0] || err)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteCourse = createResource({
|
const deleteCourse = createResource({
|
||||||
@@ -515,18 +607,11 @@ watch(
|
|||||||
() => props.courseName !== 'new',
|
() => props.courseName !== 'new',
|
||||||
(newVal) => {
|
(newVal) => {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
courseResource.reload()
|
fetchCourseInfo()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const validateFile = (file) => {
|
|
||||||
let extension = file.name.split('.').pop().toLowerCase()
|
|
||||||
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
|
|
||||||
return __('Only image file is allowed.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateTags = () => {
|
const updateTags = () => {
|
||||||
if (newTag.value) {
|
if (newTag.value) {
|
||||||
course.tags = course.tags ? `${course.tags}, ${newTag.value}` : newTag.value
|
course.tags = course.tags ? `${course.tags}, ${newTag.value}` : newTag.value
|
||||||
|
|||||||
@@ -14,20 +14,18 @@
|
|||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus class="h-4 w-4 stroke-1.5" />
|
<Plus class="h-4 w-4 stroke-1.5" />
|
||||||
</template>
|
</template>
|
||||||
{{ __('New') }}
|
{{ __('Create') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
</header>
|
</header>
|
||||||
<div class="p-5 pb-10">
|
<div class="p-5 pb-10">
|
||||||
<div
|
<div
|
||||||
v-if="courseCount"
|
|
||||||
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
|
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between mb-5"
|
||||||
>
|
>
|
||||||
<div class="text-lg text-ink-gray-9 font-semibold">
|
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||||
{{ __('All Courses') }}
|
{{ __('All Courses') }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="courses.data?.length || courseCount"
|
|
||||||
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
|
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:items-center lg:space-x-4"
|
||||||
>
|
>
|
||||||
<TabButtons :buttons="courseTabs" v-model="currentTab" />
|
<TabButtons :buttons="courseTabs" v-model="currentTab" />
|
||||||
@@ -59,7 +57,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="courses.data?.length"
|
v-if="courses.data?.length"
|
||||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-4"
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-8"
|
||||||
>
|
>
|
||||||
<router-link
|
<router-link
|
||||||
v-for="course in courses.data"
|
v-for="course in courses.data"
|
||||||
@@ -172,6 +170,8 @@ const identifyUserPersona = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getCourseCount = () => {
|
const getCourseCount = () => {
|
||||||
|
if (!user.data) return
|
||||||
|
|
||||||
call('frappe.client.get_count', {
|
call('frappe.client.get_count', {
|
||||||
doctype: 'LMS Course',
|
doctype: 'LMS Course',
|
||||||
}).then((data) => {
|
}).then((data) => {
|
||||||
@@ -240,7 +240,6 @@ const updateTabFilter = () => {
|
|||||||
filters.value['live'] = 1
|
filters.value['live'] = 1
|
||||||
} else if (currentTab.value == 'Upcoming') {
|
} else if (currentTab.value == 'Upcoming') {
|
||||||
filters.value['upcoming'] = 1
|
filters.value['upcoming'] = 1
|
||||||
filters.value['published'] = 1
|
|
||||||
} else if (currentTab.value == 'New') {
|
} else if (currentTab.value == 'New') {
|
||||||
filters.value['published'] = 1
|
filters.value['published'] = 1
|
||||||
filters.value['published_on'] = [
|
filters.value['published_on'] = [
|
||||||
@@ -249,6 +248,8 @@ const updateTabFilter = () => {
|
|||||||
]
|
]
|
||||||
} else if (currentTab.value == 'Created') {
|
} else if (currentTab.value == 'Created') {
|
||||||
filters.value['created'] = 1
|
filters.value['created'] = 1
|
||||||
|
} else if (currentTab.value == 'Unpublished') {
|
||||||
|
filters.value['published'] = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,6 +319,7 @@ const courseTabs = computed(() => {
|
|||||||
user.data?.is_evaluator
|
user.data?.is_evaluator
|
||||||
) {
|
) {
|
||||||
tabs.push({ label: __('Created') })
|
tabs.push({ label: __('Created') })
|
||||||
|
tabs.push({ label: __('Unpublished') })
|
||||||
} else if (user.data) {
|
} else if (user.data) {
|
||||||
tabs.push({ label: __('Enrolled') })
|
tabs.push({ label: __('Enrolled') })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ import { computed, onMounted, reactive, inject } from 'vue'
|
|||||||
import { FileText, X } from 'lucide-vue-next'
|
import { FileText, X } from 'lucide-vue-next'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { getFileSize } from '@/utils'
|
import { getFileSize, validateFile } from '@/utils'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -293,13 +293,6 @@ const removeImage = () => {
|
|||||||
job.image = null
|
job.image = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateFile = (file) => {
|
|
||||||
let extension = file.name.split('.').pop().toLowerCase()
|
|
||||||
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
|
||||||
return 'Only image file is allowed.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const jobTypes = computed(() => {
|
const jobTypes = computed(() => {
|
||||||
return [
|
return [
|
||||||
{ label: 'Full Time', value: 'Full Time' },
|
{ label: 'Full Time', value: 'Full Time' },
|
||||||
|
|||||||
@@ -26,7 +26,6 @@
|
|||||||
</header>
|
</header>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
v-if="jobCount"
|
|
||||||
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between w-full md:w-4/5 mx-auto p-5"
|
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between w-full md:w-4/5 mx-auto p-5"
|
||||||
>
|
>
|
||||||
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
|
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
|
||||||
@@ -34,8 +33,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="jobs.data?.length || jobCount > 0"
|
class="grid grid-cols-1 gap-2"
|
||||||
class="grid grid-cols-1 md:grid-cols-3 gap-2"
|
:class="user.data ? 'md:grid-cols-3' : 'md:grid-cols-2'"
|
||||||
>
|
>
|
||||||
<FormControl
|
<FormControl
|
||||||
type="text"
|
type="text"
|
||||||
@@ -52,6 +51,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<Link
|
<Link
|
||||||
|
v-if="user.data"
|
||||||
doctype="Country"
|
doctype="Country"
|
||||||
v-model="country"
|
v-model="country"
|
||||||
:placeholder="__('Country')"
|
:placeholder="__('Country')"
|
||||||
@@ -117,7 +117,6 @@ onMounted(() => {
|
|||||||
jobType.value = queries.get('type')
|
jobType.value = queries.get('type')
|
||||||
}
|
}
|
||||||
updateJobs()
|
updateJobs()
|
||||||
getJobCount()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const jobs = createResource({
|
const jobs = createResource({
|
||||||
@@ -163,22 +162,14 @@ const updateFilters = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getJobCount = () => {
|
|
||||||
call('frappe.client.get_count', {
|
|
||||||
doctype: 'Job Opportunity',
|
|
||||||
filters: {
|
|
||||||
status: 'Open',
|
|
||||||
disabled: 0,
|
|
||||||
},
|
|
||||||
}).then((data) => {
|
|
||||||
jobCount.value = data
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(country, (val) => {
|
watch(country, (val) => {
|
||||||
updateJobs()
|
updateJobs()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(jobs, () => {
|
||||||
|
jobCount.value = jobs.data?.length || 0
|
||||||
|
})
|
||||||
|
|
||||||
const jobTypes = computed(() => {
|
const jobTypes = computed(() => {
|
||||||
return [
|
return [
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -12,6 +12,12 @@
|
|||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Button v-if="canSeeStats()" @click="showVideoStats()">
|
||||||
|
<template #prefix>
|
||||||
|
<TrendingUp class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Video Statistics') }}
|
||||||
|
</Button>
|
||||||
<CertificationLinks :courseName="courseName" />
|
<CertificationLinks :courseName="courseName" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -65,7 +71,7 @@
|
|||||||
<div
|
<div
|
||||||
class="border-r container pt-5 pb-10 px-5 h-full"
|
class="border-r container pt-5 pb-10 px-5 h-full"
|
||||||
:class="{
|
:class="{
|
||||||
'w-full md:w-3/4 mx-auto border-none !pt-10': zenModeEnabled,
|
'w-full md:w-3/5 mx-auto border-none !pt-10': zenModeEnabled,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -100,18 +106,7 @@
|
|||||||
<MessageCircleQuestion class="w-4 h-4 stroke-1.5" />
|
<MessageCircleQuestion class="w-4 h-4 stroke-1.5" />
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
<router-link
|
<Button v-if="lesson.data.prev" @click="switchLesson('prev')">
|
||||||
v-if="lesson.data.prev"
|
|
||||||
:to="{
|
|
||||||
name: 'Lesson',
|
|
||||||
params: {
|
|
||||||
courseName: courseName,
|
|
||||||
chapterNumber: lesson.data.prev.split('.')[0],
|
|
||||||
lessonNumber: lesson.data.prev.split('.')[1],
|
|
||||||
},
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Button>
|
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<ChevronLeft class="w-4 h-4 stroke-1" />
|
<ChevronLeft class="w-4 h-4 stroke-1" />
|
||||||
</template>
|
</template>
|
||||||
@@ -119,7 +114,7 @@
|
|||||||
{{ __('Previous') }}
|
{{ __('Previous') }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
|
||||||
<router-link
|
<router-link
|
||||||
v-if="allowEdit()"
|
v-if="allowEdit()"
|
||||||
:to="{
|
:to="{
|
||||||
@@ -135,18 +130,8 @@
|
|||||||
{{ __('Edit') }}
|
{{ __('Edit') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
|
||||||
v-if="lesson.data.next"
|
<Button v-if="lesson.data.next" @click="switchLesson('next')">
|
||||||
:to="{
|
|
||||||
name: 'Lesson',
|
|
||||||
params: {
|
|
||||||
courseName: courseName,
|
|
||||||
chapterNumber: lesson.data.next.split('.')[0],
|
|
||||||
lessonNumber: lesson.data.next.split('.')[1],
|
|
||||||
},
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Button>
|
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<ChevronRight class="w-4 h-4 stroke-1" />
|
<ChevronRight class="w-4 h-4 stroke-1" />
|
||||||
</template>
|
</template>
|
||||||
@@ -154,7 +139,7 @@
|
|||||||
{{ __('Next') }}
|
{{ __('Next') }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
|
||||||
<router-link
|
<router-link
|
||||||
v-else
|
v-else
|
||||||
:to="{
|
:to="{
|
||||||
@@ -262,13 +247,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<VideoStatistics
|
||||||
|
v-model="showStatsDialog"
|
||||||
|
:lessonName="lesson.data?.name"
|
||||||
|
:lessonTitle="lesson.data?.title"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
createResource,
|
|
||||||
Badge,
|
Badge,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
Button,
|
Button,
|
||||||
|
call,
|
||||||
|
createResource,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
usePageMeta,
|
usePageMeta,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
@@ -292,17 +283,21 @@ import {
|
|||||||
Focus,
|
Focus,
|
||||||
Info,
|
Info,
|
||||||
MessageCircleQuestion,
|
MessageCircleQuestion,
|
||||||
|
TrendingUp,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import Discussions from '@/components/Discussions.vue'
|
import Discussions from '@/components/Discussions.vue'
|
||||||
import { getEditorTools, enablePlyr } from '@/utils'
|
import { getEditorTools, enablePlyr } from '@/utils'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import { useSidebar } from '@/stores/sidebar'
|
||||||
import EditorJS from '@editorjs/editorjs'
|
import EditorJS from '@editorjs/editorjs'
|
||||||
import LessonContent from '@/components/LessonContent.vue'
|
import LessonContent from '@/components/LessonContent.vue'
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
import ProgressBar from '@/components/ProgressBar.vue'
|
import ProgressBar from '@/components/ProgressBar.vue'
|
||||||
import CertificationLinks from '@/components/CertificationLinks.vue'
|
import CertificationLinks from '@/components/CertificationLinks.vue'
|
||||||
|
import VideoStatistics from '@/components/Modals/VideoStatistics.vue'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
const socket = inject('$socket')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const allowDiscussions = ref(false)
|
const allowDiscussions = ref(false)
|
||||||
@@ -311,10 +306,13 @@ const instructorEditor = ref(null)
|
|||||||
const lessonProgress = ref(0)
|
const lessonProgress = ref(0)
|
||||||
const lessonContainer = ref(null)
|
const lessonContainer = ref(null)
|
||||||
const zenModeEnabled = ref(false)
|
const zenModeEnabled = ref(false)
|
||||||
|
const showStatsDialog = ref(false)
|
||||||
const hasQuiz = ref(false)
|
const hasQuiz = ref(false)
|
||||||
const discussionsContainer = ref(null)
|
const discussionsContainer = ref(null)
|
||||||
const timer = ref(0)
|
const timer = ref(0)
|
||||||
const { brand } = sessionStore()
|
const { brand } = sessionStore()
|
||||||
|
const sidebarStore = useSidebar()
|
||||||
|
const plyrSources = ref([])
|
||||||
let timerInterval
|
let timerInterval
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -334,7 +332,13 @@ const props = defineProps({
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
startTimer()
|
startTimer()
|
||||||
|
sidebarStore.isSidebarCollapsed = true
|
||||||
document.addEventListener('fullscreenchange', attachFullscreenEvent)
|
document.addEventListener('fullscreenchange', attachFullscreenEvent)
|
||||||
|
socket.on('update_lesson_progress', (data) => {
|
||||||
|
if (data.course === props.courseName) {
|
||||||
|
lessonProgress.value = data.progress
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const attachFullscreenEvent = () => {
|
const attachFullscreenEvent = () => {
|
||||||
@@ -351,6 +355,8 @@ const attachFullscreenEvent = () => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('fullscreenchange', attachFullscreenEvent)
|
document.removeEventListener('fullscreenchange', attachFullscreenEvent)
|
||||||
|
sidebarStore.isSidebarCollapsed = false
|
||||||
|
trackVideoWatchDuration()
|
||||||
})
|
})
|
||||||
|
|
||||||
const lesson = createResource({
|
const lesson = createResource({
|
||||||
@@ -446,13 +452,39 @@ const breadcrumbs = computed(() => {
|
|||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const switchLesson = (direction) => {
|
||||||
|
trackVideoWatchDuration()
|
||||||
|
let lessonIndex =
|
||||||
|
direction === 'prev'
|
||||||
|
? lesson.data.prev.split('.')
|
||||||
|
: lesson.data.next.split('.')
|
||||||
|
|
||||||
|
router.push({
|
||||||
|
name: 'Lesson',
|
||||||
|
params: {
|
||||||
|
courseName: props.courseName,
|
||||||
|
chapterNumber: lessonIndex[0],
|
||||||
|
lessonNumber: lessonIndex[1],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[() => route.params.chapterNumber, () => route.params.lessonNumber],
|
[() => route.params.chapterNumber, () => route.params.lessonNumber],
|
||||||
(
|
async (
|
||||||
[newChapterNumber, newLessonNumber],
|
[newChapterNumber, newLessonNumber],
|
||||||
[oldChapterNumber, oldLessonNumber]
|
[oldChapterNumber, oldLessonNumber]
|
||||||
) => {
|
) => {
|
||||||
if (newChapterNumber || newLessonNumber) {
|
if (newChapterNumber || newLessonNumber) {
|
||||||
|
plyrSources.value = []
|
||||||
|
await nextTick()
|
||||||
|
resetLessonState(newChapterNumber, newLessonNumber)
|
||||||
|
startTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const resetLessonState = (newChapterNumber, newLessonNumber) => {
|
||||||
editor.value = null
|
editor.value = null
|
||||||
instructorEditor.value = null
|
instructorEditor.value = null
|
||||||
allowDiscussions.value = false
|
allowDiscussions.value = false
|
||||||
@@ -462,22 +494,117 @@ watch(
|
|||||||
})
|
})
|
||||||
clearInterval(timerInterval)
|
clearInterval(timerInterval)
|
||||||
timer.value = 0
|
timer.value = 0
|
||||||
startTimer()
|
|
||||||
enablePlyr()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trackVideoWatchDuration = () => {
|
||||||
|
if (!lesson.data.membership) return
|
||||||
|
let videoDetails = getVideoDetails()
|
||||||
|
videoDetails = videoDetails.concat(getPlyrSourceDetails())
|
||||||
|
call('lms.lms.api.track_video_watch_duration', {
|
||||||
|
lesson: lesson.data.name,
|
||||||
|
videos: videoDetails,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getVideoDetails = () => {
|
||||||
|
let details = []
|
||||||
|
const videos = document.querySelectorAll('video')
|
||||||
|
if (videos.length > 0) {
|
||||||
|
videos.forEach((video) => {
|
||||||
|
if (video.currentTime == video.duration) markProgress()
|
||||||
|
details.push({
|
||||||
|
source: video.src,
|
||||||
|
watch_time: video.currentTime,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return details
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPlyrSourceDetails = () => {
|
||||||
|
let details = []
|
||||||
|
plyrSources.value.forEach((source) => {
|
||||||
|
if (source.currentTime == source.duration) markProgress()
|
||||||
|
let src = cleanYouTubeUrl(source.source)
|
||||||
|
details.push({
|
||||||
|
source: src,
|
||||||
|
watch_time: source.currentTime,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return details
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanYouTubeUrl = (url) => {
|
||||||
|
if (!url) return url
|
||||||
|
const urlObj = new URL(url)
|
||||||
|
urlObj.searchParams.delete('t')
|
||||||
|
return urlObj.toString()
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => lesson.data,
|
() => lesson.data,
|
||||||
(data) => {
|
async (data) => {
|
||||||
setupLesson(data)
|
setupLesson(data)
|
||||||
enablePlyr()
|
getPlyrSource()
|
||||||
|
if (data.icon == 'icon-youtube') clearInterval(timerInterval)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const getPlyrSource = async () => {
|
||||||
|
await nextTick()
|
||||||
|
if (plyrSources.value.length == 0) {
|
||||||
|
plyrSources.value = await enablePlyr()
|
||||||
|
}
|
||||||
|
updateVideoWatchDuration()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateVideoWatchDuration = () => {
|
||||||
|
if (lesson.data.videos && lesson.data.videos.length > 0) {
|
||||||
|
lesson.data.videos.forEach((video) => {
|
||||||
|
if (video.source.includes('youtube') || video.source.includes('vimeo')) {
|
||||||
|
updatePlyrVideoTime(video)
|
||||||
|
} else {
|
||||||
|
updateVideoTime(video)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePlyrVideoTime = (video) => {
|
||||||
|
plyrSources.value.forEach((plyrSource) => {
|
||||||
|
let lastWatchedTime = 0
|
||||||
|
let isSeeking = false
|
||||||
|
|
||||||
|
plyrSource.on('ready', () => {
|
||||||
|
if (plyrSource.source === video.source) {
|
||||||
|
plyrSource.embed.seekTo(video.watch_time, true)
|
||||||
|
plyrSource.play()
|
||||||
|
plyrSource.pause()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateVideoTime = (video) => {
|
||||||
|
const videos = document.querySelectorAll('video')
|
||||||
|
if (videos.length > 0) {
|
||||||
|
videos.forEach((vid) => {
|
||||||
|
if (vid.src === video.source) {
|
||||||
|
let watch_time = video.watch_time < vid.duration ? video.watch_time : 0
|
||||||
|
if (vid.readyState >= 1) {
|
||||||
|
vid.currentTime = watch_time
|
||||||
|
} else {
|
||||||
|
vid.addEventListener('loadedmetadata', () => {
|
||||||
|
vid.currentTime = watch_time
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const startTimer = () => {
|
const startTimer = () => {
|
||||||
timerInterval = setInterval(() => {
|
let timerInterval = setInterval(() => {
|
||||||
timer.value++
|
timer.value++
|
||||||
if (timer.value == 30) {
|
if (timer.value == 30) {
|
||||||
clearInterval(timerInterval)
|
clearInterval(timerInterval)
|
||||||
@@ -542,13 +669,22 @@ const enrollStudent = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canSeeStats = () => {
|
||||||
|
if (user.data?.is_moderator || user.data?.is_instructor) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const showVideoStats = () => {
|
||||||
|
showStatsDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const canGoZen = () => {
|
const canGoZen = () => {
|
||||||
if (
|
if (
|
||||||
user.data?.is_moderator ||
|
user.data?.is_moderator ||
|
||||||
user.data?.is_instructor ||
|
user.data?.is_instructor ||
|
||||||
user.data?.is_evaluator
|
user.data?.is_evaluator
|
||||||
)
|
)
|
||||||
return false
|
return true
|
||||||
if (lesson.data?.membership) return true
|
if (lesson.data?.membership) return true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ import EditorJS from '@editorjs/editorjs'
|
|||||||
import LessonHelp from '@/components/LessonHelp.vue'
|
import LessonHelp from '@/components/LessonHelp.vue'
|
||||||
import { ChevronRight } from 'lucide-vue-next'
|
import { ChevronRight } from 'lucide-vue-next'
|
||||||
import { getEditorTools, enablePlyr } from '@/utils'
|
import { getEditorTools, enablePlyr } from '@/utils'
|
||||||
import { capture } from '@/telemetry'
|
import { capture, startRecording, stopRecording } from '@/telemetry'
|
||||||
import { useOnboarding } from 'frappe-ui/frappe'
|
import { useOnboarding } from 'frappe-ui/frappe'
|
||||||
|
|
||||||
const { brand } = sessionStore()
|
const { brand } = sessionStore()
|
||||||
@@ -131,6 +131,7 @@ onMounted(() => {
|
|||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
capture('lesson_form_opened')
|
capture('lesson_form_opened')
|
||||||
|
startRecording()
|
||||||
editor.value = renderEditor('content')
|
editor.value = renderEditor('content')
|
||||||
instructorEditor.value = renderEditor('instructor-notes')
|
instructorEditor.value = renderEditor('instructor-notes')
|
||||||
window.addEventListener('keydown', keyboardShortcut)
|
window.addEventListener('keydown', keyboardShortcut)
|
||||||
@@ -226,6 +227,7 @@ const keyboardShortcut = (e) => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
clearInterval(autoSaveInterval)
|
clearInterval(autoSaveInterval)
|
||||||
window.removeEventListener('keydown', keyboardShortcut)
|
window.removeEventListener('keydown', keyboardShortcut)
|
||||||
|
stopRecording()
|
||||||
})
|
})
|
||||||
|
|
||||||
const newLessonResource = createResource({
|
const newLessonResource = createResource({
|
||||||
@@ -383,8 +385,10 @@ const saveLesson = (e) => {
|
|||||||
showSuccessMessage = true
|
showSuccessMessage = true
|
||||||
}
|
}
|
||||||
editor.value.save().then((outputData) => {
|
editor.value.save().then((outputData) => {
|
||||||
|
outputData = removeEmptyBlocks(outputData)
|
||||||
lesson.content = JSON.stringify(outputData)
|
lesson.content = JSON.stringify(outputData)
|
||||||
instructorEditor.value.save().then((outputData) => {
|
instructorEditor.value.save().then((outputData) => {
|
||||||
|
outputData = removeEmptyBlocks(outputData)
|
||||||
lesson.instructor_content = JSON.stringify(outputData)
|
lesson.instructor_content = JSON.stringify(outputData)
|
||||||
if (lessonDetails.data?.lesson) {
|
if (lessonDetails.data?.lesson) {
|
||||||
editCurrentLesson()
|
editCurrentLesson()
|
||||||
@@ -395,6 +399,14 @@ const saveLesson = (e) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removeEmptyBlocks = (outputData) => {
|
||||||
|
let blocks = outputData.blocks.filter((block) => {
|
||||||
|
return Object.keys(block.data).length > 0 || block.type == 'paragraph'
|
||||||
|
})
|
||||||
|
outputData.blocks = blocks
|
||||||
|
return outputData
|
||||||
|
}
|
||||||
|
|
||||||
const createNewLesson = () => {
|
const createNewLesson = () => {
|
||||||
newLessonResource.submit(
|
newLessonResource.submit(
|
||||||
{},
|
{},
|
||||||
@@ -651,6 +663,57 @@ iframe {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ce-popover__container {
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-search-field {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-search-field__input {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-search-field__input::before {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-search-field__input:focus {
|
||||||
|
--tw-ring-color: theme('colors.gray.100');
|
||||||
|
}
|
||||||
|
|
||||||
|
.ce-popover-item__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ce-popover-item__icon svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ce-popover--opened > .ce-popover__container {
|
||||||
|
max-height: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-search-field__icon svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-search-field__icon {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-block.embed-tool {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--plyr-range-fill-background: white;
|
--plyr-range-fill-background: white;
|
||||||
--plyr-video-control-background-hover: transparent;
|
--plyr-video-control-background-hover: transparent;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<div
|
<div
|
||||||
v-if="notifications?.length"
|
v-if="notifications?.length"
|
||||||
v-for="log in notifications"
|
v-for="log in notifications"
|
||||||
|
:key="log.name"
|
||||||
class="flex items-center py-2 justify-between"
|
class="flex items-center py-2 justify-between"
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -32,22 +33,20 @@
|
|||||||
<Link
|
<Link
|
||||||
v-if="log.link"
|
v-if="log.link"
|
||||||
:to="log.link"
|
:to="log.link"
|
||||||
@click="markAsRead.submit({ name: log.name })"
|
@click="(e) => handleMarkAsRead(e, log.name)"
|
||||||
class="text-ink-gray-5 font-medium text-sm hover:text-ink-gray-7"
|
class="text-ink-gray-5 font-medium text-sm hover:text-ink-gray-7"
|
||||||
>
|
>
|
||||||
{{ __('View') }}
|
{{ __('View') }}
|
||||||
</Link>
|
</Link>
|
||||||
<Tooltip :text="__('Mark as read')">
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
v-if="!log.read"
|
v-if="!log.read"
|
||||||
@click="markAsRead.submit({ name: log.name })"
|
@click.stop="(e) => handleMarkAsRead(e, log.name)"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<X class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
|
<X class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-ink-gray-5">
|
<div v-else class="text-ink-gray-5">
|
||||||
@@ -64,11 +63,10 @@ import {
|
|||||||
Link,
|
Link,
|
||||||
TabButtons,
|
TabButtons,
|
||||||
Button,
|
Button,
|
||||||
Tooltip,
|
|
||||||
usePageMeta,
|
usePageMeta,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { sessionStore } from '../stores/session'
|
import { sessionStore } from '../stores/session'
|
||||||
import { computed, inject, ref, onMounted } from 'vue'
|
import { computed, inject, ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { X } from 'lucide-vue-next'
|
import { X } from 'lucide-vue-next'
|
||||||
|
|
||||||
@@ -135,6 +133,14 @@ const markAllAsRead = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleMarkAsRead = (e, logName) => {
|
||||||
|
markAsRead.submit({ name: logName })
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
socket.off('publish_lms_notifications')
|
||||||
|
})
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let crumbs = [
|
let crumbs = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ const persona = reactive({
|
|||||||
const submitPersona = () => {
|
const submitPersona = () => {
|
||||||
let responses = {
|
let responses = {
|
||||||
site: user.data?.sitename,
|
site: user.data?.sitename,
|
||||||
no_of_students: persona.noOfStudents,
|
role: persona.role,
|
||||||
use_case: persona.useCase,
|
use_case: persona.useCase,
|
||||||
}
|
}
|
||||||
call('lms.lms.api.capture_user_persona', {
|
call('lms.lms.api.capture_user_persona', {
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
v-model="activeTab"
|
v-model="activeTab"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<router-view :profile="profile" />
|
<router-view :profile="profile" :key="profile.data?.name" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<EditProfile
|
<EditProfile
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const evaluations = createListResource({
|
|||||||
doctype: 'LMS Certificate Request',
|
doctype: 'LMS Certificate Request',
|
||||||
filters: {
|
filters: {
|
||||||
evaluator: user.data?.name,
|
evaluator: user.data?.name,
|
||||||
|
status: ['!=', 'Cancelled'],
|
||||||
},
|
},
|
||||||
fields: [
|
fields: [
|
||||||
'name',
|
'name',
|
||||||
@@ -76,7 +77,7 @@ const evaluations = createListResource({
|
|||||||
],
|
],
|
||||||
auto: true,
|
auto: true,
|
||||||
orderBy: 'creation desc',
|
orderBy: 'creation desc',
|
||||||
limit: 100,
|
pageLength: 500,
|
||||||
cache: ['schedule', user.data?.name],
|
cache: ['schedule', user.data?.name],
|
||||||
transform(data) {
|
transform(data) {
|
||||||
return data.map((d) => {
|
return data.map((d) => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user