Compare commits
874 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57c1a6b540 | ||
|
|
8dba0e8242 | ||
|
|
ee715f6387 | ||
|
|
b770b30334 | ||
|
|
d61abac126 | ||
|
|
ccf28b8012 | ||
|
|
3762cb06bb | ||
|
|
15400f2a3e | ||
|
|
20d1b1fe83 | ||
|
|
73844f8813 | ||
|
|
2187553625 | ||
|
|
984b2a5dea | ||
|
|
9098d9454f | ||
|
|
027dd93fb5 | ||
|
|
a005adc89a | ||
|
|
866ef04fbf | ||
|
|
00b6f97e3a | ||
|
|
a1d21b1a2a | ||
|
|
7358ea43d8 | ||
|
|
88c69311eb | ||
|
|
c1e45e5d0d | ||
|
|
fe78de2417 | ||
|
|
4c1fc201e6 | ||
|
|
3f5d270915 | ||
|
|
a452fbeb07 | ||
|
|
a6f02c245f | ||
|
|
cb4f9129d6 | ||
|
|
9c5d64c211 | ||
|
|
41dc0ecc60 | ||
|
|
6b9409b889 | ||
|
|
ea66eeed6c | ||
|
|
a419d28ef1 | ||
|
|
481dfc24fd | ||
|
|
ed686a7d52 | ||
|
|
b4c5a07800 | ||
|
|
6ae16f7fef | ||
|
|
4aae2ed3b8 | ||
|
|
81d4137b20 | ||
|
|
77ecb02a17 | ||
|
|
4a375f92ed | ||
|
|
7caf91460a | ||
|
|
0e015c8b97 | ||
|
|
7b69ddb14d | ||
|
|
2271eb270e | ||
|
|
7e5b2e4e79 | ||
|
|
124b9d9ea5 | ||
|
|
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 | ||
|
|
f375ffb8f8 | ||
|
|
de240e40a5 | ||
|
|
7d30aea07f | ||
|
|
04a7361d0d | ||
|
|
7b19618eca | ||
|
|
bd9600cc08 | ||
|
|
32172bc791 | ||
|
|
c92f57fb07 | ||
|
|
8fbdea7f36 | ||
|
|
df15da5145 | ||
|
|
846fe53c0f | ||
|
|
3bbdc828d9 | ||
|
|
c454c3f0f2 | ||
|
|
77b1a546e8 | ||
|
|
7c7f063204 | ||
|
|
0a0fcb305c | ||
|
|
da8028784d | ||
|
|
48edd888a6 | ||
|
|
da4f134095 | ||
|
|
0a71620046 | ||
|
|
1b5a762578 | ||
|
|
d9d031ed2b | ||
|
|
403e56b4ef | ||
|
|
499b06e300 | ||
|
|
cb69540bdd | ||
|
|
1f27fa419a | ||
|
|
a561b2bd91 | ||
|
|
eeec85d1de | ||
|
|
e01484f854 | ||
|
|
fb996ded88 | ||
|
|
a11bfca15a | ||
|
|
6262e1c9e6 | ||
|
|
4e318af7cc | ||
|
|
d587b7867e | ||
|
|
bd03ead9c3 | ||
|
|
c1685b7128 | ||
|
|
7625e79574 | ||
|
|
c5bf7875b9 | ||
|
|
da026293bc | ||
|
|
86e5677574 | ||
|
|
a48636604f | ||
|
|
e6945ac076 | ||
|
|
9107d76522 | ||
|
|
52b925b306 | ||
|
|
49d3dc0aa0 | ||
|
|
0d41a1ae70 | ||
|
|
49e22d790a | ||
|
|
12e5eedd6b | ||
|
|
159b651871 | ||
|
|
080be7a885 | ||
|
|
e526627eb9 | ||
|
|
67fc37c76c | ||
|
|
b2b92aea31 | ||
|
|
e0680d9612 | ||
|
|
d54ac37403 | ||
|
|
eedb3d3dd8 | ||
|
|
015aff9c4b | ||
|
|
d286df649e | ||
|
|
567bfc41e0 | ||
|
|
90d77e9ffb | ||
|
|
2b33ba1984 | ||
|
|
1918f0c5d5 | ||
|
|
91d79de723 | ||
|
|
62b05f2377 | ||
|
|
b628ec4c57 | ||
|
|
494394f084 | ||
|
|
e99b4b183c | ||
|
|
9186353654 | ||
|
|
bd2a7b9095 | ||
|
|
42b70e7a94 | ||
|
|
7f913203a1 | ||
|
|
9b94958840 | ||
|
|
2070e93379 | ||
|
|
772f4d938f | ||
|
|
531f3af203 | ||
|
|
ed522341c1 | ||
|
|
ee59c5068e | ||
|
|
ebe3abd05b | ||
|
|
358dd4dddc | ||
|
|
3d924d3631 | ||
|
|
0bed316a40 | ||
|
|
24b5937793 | ||
|
|
c5b5876700 | ||
|
|
0f969e952d | ||
|
|
43ba512fd5 | ||
|
|
e0cbc247b2 | ||
|
|
a2c8a82559 | ||
|
|
8aadbffe8c | ||
|
|
be7e7bc6fd | ||
|
|
3a10d4bdc0 | ||
|
|
fc03ecd1b3 | ||
|
|
c7b10f0e83 | ||
|
|
6a94ce5e1c | ||
|
|
59859a8e2f | ||
|
|
f51a8aae39 | ||
|
|
bd5b8c5e0e | ||
|
|
67e7744566 | ||
|
|
65a6663c31 | ||
|
|
603e80fd26 | ||
|
|
de4ee6bbe6 | ||
|
|
a8aa242280 | ||
|
|
0d32c2a9d9 | ||
|
|
6d5a02e2a8 | ||
|
|
67f3cbaaa8 | ||
|
|
f17504e1a0 | ||
|
|
b1a9af5de8 | ||
|
|
913bf553ae | ||
|
|
356dcc42bf | ||
|
|
8c006f24ce | ||
|
|
6f2f0092f0 | ||
|
|
56afc4c614 | ||
|
|
0a3b9f8f9a | ||
|
|
9b0623f4a4 | ||
|
|
c13ef17a86 | ||
|
|
d5ac2f521f | ||
|
|
037af18114 | ||
|
|
92299458f5 | ||
|
|
3272f2a4cf | ||
|
|
6a6dfdd82c | ||
|
|
fa27452983 | ||
|
|
8df5ec41d5 | ||
|
|
55aad3a742 | ||
|
|
e46890d87e | ||
|
|
3a36e10fce | ||
|
|
cc30c6d271 | ||
|
|
5e75ff7fb7 | ||
|
|
80681a1f8b | ||
|
|
5954e10155 | ||
|
|
78c43b7a10 | ||
|
|
8c6f8bf97b | ||
|
|
f220438257 | ||
|
|
bbd06752d3 | ||
|
|
e34df2ce95 | ||
|
|
b197c08716 | ||
|
|
aeb6c0f433 | ||
|
|
8f32767267 | ||
|
|
afd43b9a9a | ||
|
|
5893e02c48 | ||
|
|
66d3325e3c | ||
|
|
e513993a0d | ||
|
|
ddbdf42265 | ||
|
|
badaa33ddb | ||
|
|
befa3d7a6d | ||
|
|
513f1e8b86 | ||
|
|
4128f0fb73 | ||
|
|
3d81a63410 | ||
|
|
c0ba44cacc | ||
|
|
deba027457 | ||
|
|
47089d286e | ||
|
|
6c50292a66 | ||
|
|
1f23f06926 | ||
|
|
63319d32e8 | ||
|
|
66f28ef7a6 | ||
|
|
4e4eccd909 | ||
|
|
c21fe99368 | ||
|
|
53ea91e945 | ||
|
|
7cde05b58a | ||
|
|
0fc9b35307 | ||
|
|
4a36826af0 | ||
|
|
26a278c5f4 | ||
|
|
66a4d79730 | ||
|
|
097d541391 | ||
|
|
788ef9b106 | ||
|
|
a38e1163af | ||
|
|
a633ff5174 | ||
|
|
6b412106de | ||
|
|
93b5cb6161 | ||
|
|
4b80fbe5eb | ||
|
|
52775aae60 | ||
|
|
0430178b3e | ||
|
|
470123c77a | ||
|
|
66d4798db3 | ||
|
|
cc39395a12 | ||
|
|
3aeb9cf0b1 | ||
|
|
f1b383f0b7 | ||
|
|
e2896b7bf0 | ||
|
|
780dfb8966 | ||
|
|
8b91323705 | ||
|
|
89fdbf5660 | ||
|
|
ac47ab3f8a | ||
|
|
7ed5dfdb8f | ||
|
|
bfc1488860 | ||
|
|
726f733434 | ||
|
|
0c97e31101 | ||
|
|
ec2b0718e6 | ||
|
|
720056268c | ||
|
|
345992eda4 | ||
|
|
e3e6b35eb7 | ||
|
|
701ea950de | ||
|
|
4b78865823 | ||
|
|
5b2bdf4cf6 | ||
|
|
a677b7fd3a | ||
|
|
9cbd3db022 | ||
|
|
5f52d2c2c7 | ||
|
|
b8c403aa5d | ||
|
|
2c6863e18e | ||
|
|
e7a462c685 | ||
|
|
0cf671ae3b | ||
|
|
dfc6f5bfb4 | ||
|
|
64b9be7e42 | ||
|
|
7412a8761c | ||
|
|
65cdeabc77 | ||
|
|
a507d4464d | ||
|
|
9143cc39d9 | ||
|
|
e821755721 | ||
|
|
d081688fc9 | ||
|
|
cdc7ee698c | ||
|
|
0d0a9c872c | ||
|
|
30953cce66 | ||
|
|
f6008cf46a | ||
|
|
eb0587f726 | ||
|
|
ba56ac87c5 | ||
|
|
5800ac67c4 | ||
|
|
73941a159a | ||
|
|
d1fe8b203a | ||
|
|
8b8dbc1053 | ||
|
|
57e477b17c | ||
|
|
1a1924de3e | ||
|
|
3bea19c8ad | ||
|
|
cd47b62765 | ||
|
|
ffeaad324e | ||
|
|
4504dd810d | ||
|
|
60ad86f79c | ||
|
|
f63294699a | ||
|
|
650594d9ea | ||
|
|
7c22d5c774 | ||
|
|
73a501908d | ||
|
|
31836e5c9e | ||
|
|
31adab94b3 | ||
|
|
824c65eb38 | ||
|
|
4e02044eb4 | ||
|
|
f245cf2c5d | ||
|
|
1b49cc1408 | ||
|
|
bd384a9b59 | ||
|
|
48eb2ff405 | ||
|
|
dcacda984f | ||
|
|
8186e9e1d2 | ||
|
|
b5b93917d1 | ||
|
|
1ffdadbde3 | ||
|
|
4506603ea1 | ||
|
|
fdf8b85f88 | ||
|
|
340264ce41 | ||
|
|
d6187b3d63 | ||
|
|
b6577133a9 | ||
|
|
2d410eac37 | ||
|
|
e63e71f2bf | ||
|
|
ba743e0480 | ||
|
|
2f26b15524 | ||
|
|
5841ed0e70 | ||
|
|
d217dff4b9 | ||
|
|
2746606db1 | ||
|
|
2d321780d0 | ||
|
|
c26108586f | ||
|
|
7f30d9c3dc | ||
|
|
816b40bdc6 | ||
|
|
09688315cb | ||
|
|
c709535442 | ||
|
|
08e2d804fa | ||
|
|
b4fb07b435 | ||
|
|
d119ae6409 | ||
|
|
cf26fc4530 | ||
|
|
f50a7704c9 | ||
|
|
facec8393c | ||
|
|
172e8872ef | ||
|
|
b7755b844a | ||
|
|
7e77d29edb | ||
|
|
3b84ef6968 | ||
|
|
2dd8192dcb | ||
|
|
cafb499a79 | ||
|
|
f952267396 | ||
|
|
6913b71c69 | ||
|
|
c485b03b83 | ||
|
|
e1f35c86db | ||
|
|
cfbe60b731 | ||
|
|
a21020e226 | ||
|
|
28d18102f0 | ||
|
|
f5e78b7fdb | ||
|
|
d420b2dae5 | ||
|
|
3cce9107d0 | ||
|
|
a5248eb92b | ||
|
|
1acf734229 | ||
|
|
cc170ecb20 | ||
|
|
b7f40d16a4 | ||
|
|
7e6cb727bd | ||
|
|
eeaa835bef | ||
|
|
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
|
||||||
|
|||||||
3
.github/workflows/make_release_pr.yml
vendored
3
.github/workflows/make_release_pr.yml
vendored
@@ -1,8 +1,7 @@
|
|||||||
name: Create weekly release
|
name: Create weekly release
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
# 13:00 UTC -> 7pm IST on every Wednesday
|
- cron: '30 3 * * 3'
|
||||||
- cron: '30 4 * * 3'
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
3
.github/workflows/ui-tests.yml
vendored
3
.github/workflows/ui-tests.yml
vendored
@@ -100,11 +100,12 @@ jobs:
|
|||||||
bench --site lms.test execute frappe.utils.install.complete_setup_wizard
|
bench --site lms.test execute frappe.utils.install.complete_setup_wizard
|
||||||
bench --site lms.test execute frappe.tests.ui_test_helpers.create_test_user
|
bench --site lms.test execute frappe.tests.ui_test_helpers.create_test_user
|
||||||
bench --site lms.test set-password frappe@example.com admin
|
bench --site lms.test set-password frappe@example.com admin
|
||||||
|
bench --site lms.test execute lms.lms.utils.persona_captured
|
||||||
|
|
||||||
- name: cypress pre-requisites
|
- name: cypress pre-requisites
|
||||||
run: |
|
run: |
|
||||||
cd ~/frappe-bench/apps/lms
|
cd ~/frappe-bench/apps/lms
|
||||||
yarn add cypress@^10 --no-lockfile
|
yarn add cypress@^10 --no-lockfile -W
|
||||||
|
|
||||||
- name: UI Tests
|
- name: UI Tests
|
||||||
run: cd ~/frappe-bench/ && bench --site lms.test run-ui-tests lms --headless
|
run: cd ~/frappe-bench/ && bench --site lms.test run-ui-tests lms --headless
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
export default {
|
||||||
parserPreset: "conventional-changelog-conventionalcommits",
|
parserPreset: "conventional-changelog-conventionalcommits",
|
||||||
rules: {
|
rules: {
|
||||||
"subject-empty": [2, "never"],
|
"subject-empty": [2, "never"],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const { defineConfig } = require("cypress");
|
import { defineConfig } from "cypress";
|
||||||
|
|
||||||
module.exports = defineConfig({
|
export default defineConfig({
|
||||||
projectId: "vandxn",
|
projectId: "vandxn",
|
||||||
adminPassword: "admin",
|
adminPassword: "admin",
|
||||||
testUser: "frappe@example.com",
|
testUser: "frappe@example.com",
|
||||||
@@ -13,6 +13,6 @@ module.exports = defineConfig({
|
|||||||
openMode: 0,
|
openMode: 0,
|
||||||
},
|
},
|
||||||
e2e: {
|
e2e: {
|
||||||
baseUrl: "http://testui:8000",
|
baseUrl: "http://pertest:8000",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
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");
|
||||||
@@ -19,7 +22,11 @@ describe("Course Creation", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
cy.fixture("profile.png", "base64").then((fileContent) => {
|
cy.fixture("profile.png", "base64").then((fileContent) => {
|
||||||
cy.get('input[type="file"]').attachFile({
|
cy.get("div")
|
||||||
|
.contains("Course Image")
|
||||||
|
.siblings("div")
|
||||||
|
.children('input[type="file"]')
|
||||||
|
.attachFile({
|
||||||
fileContent,
|
fileContent,
|
||||||
fileName: "profile.png",
|
fileName: "profile.png",
|
||||||
mimeType: "image/png",
|
mimeType: "image/png",
|
||||||
@@ -92,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");
|
||||||
});
|
});
|
||||||
@@ -132,6 +140,7 @@ describe("Course Creation", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Add Discussion
|
// Add Discussion
|
||||||
|
cy.get("span").contains("Community").click();
|
||||||
cy.button("New Question").click();
|
cy.button("New Question").click();
|
||||||
cy.wait(500);
|
cy.wait(500);
|
||||||
cy.get("[id^=headlessui-dialog-panel-").within(() => {
|
cy.get("[id^=headlessui-dialog-panel-").within(() => {
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ cd frappe-bench
|
|||||||
|
|
||||||
# Use containers instead of localhost
|
# Use containers instead of localhost
|
||||||
bench set-mariadb-host mariadb
|
bench set-mariadb-host mariadb
|
||||||
bench set-redis-cache-host redis:6379
|
bench set-redis-cache-host redis://redis:6379
|
||||||
bench set-redis-queue-host redis:6379
|
bench set-redis-queue-host redis://redis:6379
|
||||||
bench set-redis-socketio-host redis:6379
|
bench set-redis-socketio-host redis://redis:6379
|
||||||
|
|
||||||
# Remove redis, watch from Procfile
|
# Remove redis, watch from Procfile
|
||||||
sed -i '/redis/d' ./Procfile
|
sed -i '/redis/d' ./Procfile
|
||||||
|
|||||||
Submodule frappe-ui updated: 29307e4fff...333dce1a4d
10
frontend/auto-imports.d.ts
vendored
Normal file
10
frontend/auto-imports.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* prettier-ignore */
|
||||||
|
// @ts-nocheck
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
// Generated by unplugin-auto-import
|
||||||
|
// biome-ignore lint: disable
|
||||||
|
export {}
|
||||||
|
declare global {
|
||||||
|
|
||||||
|
}
|
||||||
40
frontend/components.d.ts
vendored
40
frontend/components.d.ts
vendored
@@ -16,8 +16,13 @@ declare module 'vue' {
|
|||||||
AssessmentPlugin: typeof import('./src/components/AssessmentPlugin.vue')['default']
|
AssessmentPlugin: typeof import('./src/components/AssessmentPlugin.vue')['default']
|
||||||
Assessments: typeof import('./src/components/Assessments.vue')['default']
|
Assessments: typeof import('./src/components/Assessments.vue')['default']
|
||||||
Assignment: typeof import('./src/components/Assignment.vue')['default']
|
Assignment: typeof import('./src/components/Assignment.vue')['default']
|
||||||
|
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
|
||||||
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
|
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']
|
||||||
@@ -26,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']
|
||||||
@@ -46,13 +55,18 @@ 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']
|
||||||
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']
|
||||||
|
InlineLessonMenu: typeof import('./src/components/Notes/InlineLessonMenu.vue')['default']
|
||||||
InviteIcon: typeof import('./src/components/Icons/InviteIcon.vue')['default']
|
InviteIcon: typeof import('./src/components/Icons/InviteIcon.vue')['default']
|
||||||
JobApplicationModal: typeof import('./src/components/Modals/JobApplicationModal.vue')['default']
|
JobApplicationModal: typeof import('./src/components/Modals/JobApplicationModal.vue')['default']
|
||||||
JobCard: typeof import('./src/components/JobCard.vue')['default']
|
JobCard: typeof import('./src/components/JobCard.vue')['default']
|
||||||
@@ -60,37 +74,45 @@ 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']
|
||||||
|
Notes: typeof import('./src/components/Notes/Notes.vue')['default']
|
||||||
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
|
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
|
||||||
OnboardingBanner: typeof import('./src/components/OnboardingBanner.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']
|
||||||
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']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,8 @@
|
|||||||
<a href="{{ meta.link }}">Know More</a>
|
<a href="{{ meta.link }}">Know More</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="modals"></div>
|
|
||||||
<div id="popovers"></div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('seo-content').style.display = 'none';
|
document.getElementById('seo-content').style.display = 'none';
|
||||||
window.csrf_token = '{{ csrf_token }}'
|
|
||||||
</script>
|
</script>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "frappe-ui-frontend",
|
"name": "frappe-ui-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
@@ -9,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",
|
||||||
@@ -23,19 +28,22 @@
|
|||||||
"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.122",
|
"frappe-ui": "0.1.173",
|
||||||
"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",
|
||||||
"pinia": "^2.0.33",
|
"pinia": "^2.0.33",
|
||||||
|
"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",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
|
|||||||
BIN
frontend/public/Remove.mp4
Normal file
BIN
frontend/public/Remove.mp4
Normal file
Binary file not shown.
@@ -1,30 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<FrappeUIProvider>
|
||||||
<Layout>
|
<Layout>
|
||||||
|
<div class="text-base">
|
||||||
<router-view />
|
<router-view />
|
||||||
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
<Dialogs />
|
<Dialogs />
|
||||||
<Toasts />
|
</FrappeUIProvider>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Toasts } 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) {
|
if (to.query.fromLesson || to.path === '/persona') {
|
||||||
noSidebar.value = true
|
noSidebar.value = true
|
||||||
} else {
|
} else {
|
||||||
noSidebar.value = false
|
noSidebar.value = false
|
||||||
@@ -38,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>
|
||||||
|
|||||||
@@ -39,7 +39,11 @@
|
|||||||
{{ __('More') }}
|
{{ __('More') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button v-if="isModerator" variant="ghost" @click="openPageModal()">
|
<Button
|
||||||
|
v-if="isModerator && !readOnlyMode"
|
||||||
|
variant="ghost"
|
||||||
|
@click="openPageModal()"
|
||||||
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Plus class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
|
<Plus class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
|
||||||
</template>
|
</template>
|
||||||
@@ -63,6 +67,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="m-2 flex flex-col gap-1">
|
<div class="m-2 flex flex-col gap-1">
|
||||||
|
<div
|
||||||
|
v-if="readOnlyMode && !sidebarStore.isSidebarCollapsed"
|
||||||
|
class="z-10 m-2 bg-surface-modal py-2.5 px-3 text-xs text-ink-gray-7 leading-5 rounded-md"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'This site is being updated. You will not be able to make any changes. Full access will be restored shortly.'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
<TrialBanner
|
<TrialBanner
|
||||||
v-if="
|
v-if="
|
||||||
userResource.data?.is_system_manager && userResource.data?.is_fc_site
|
userResource.data?.is_system_manager && userResource.data?.is_fc_site
|
||||||
@@ -74,43 +88,69 @@
|
|||||||
:isSidebarCollapsed="sidebarStore.isSidebarCollapsed"
|
:isSidebarCollapsed="sidebarStore.isSidebarCollapsed"
|
||||||
appName="learning"
|
appName="learning"
|
||||||
/>
|
/>
|
||||||
<SidebarLink
|
|
||||||
v-if="isOnboardingStepsCompleted"
|
<div
|
||||||
:link="{
|
class="flex items-center mt-4"
|
||||||
label: __('Help'),
|
:class="
|
||||||
}"
|
sidebarStore.isSidebarCollapsed ? 'flex-col space-y-3' : 'flex-row'
|
||||||
:isCollapsed="sidebarStore.isSidebarCollapsed"
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center flex-1"
|
||||||
|
:class="
|
||||||
|
sidebarStore.isSidebarCollapsed
|
||||||
|
? 'flex-col space-y-3'
|
||||||
|
: 'flex-row space-x-3'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Tooltip v-if="readOnlyMode && sidebarStore.isSidebarCollapsed">
|
||||||
|
<CircleAlert
|
||||||
|
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<template #body>
|
||||||
|
<div
|
||||||
|
class="max-w-[30ch] rounded bg-surface-gray-7 px-2 py-1 text-center text-p-xs text-ink-white shadow-xl"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'This site is being updated. You will not be able to make any changes. Full access will be restored shortly.'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip :text="__('Powered by Learning')">
|
||||||
|
<Zap
|
||||||
|
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||||
|
@click="redirectToWebsite()"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip v-if="showOnboarding" :text="__('Help')">
|
||||||
|
<CircleHelp
|
||||||
|
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||||
@click="
|
@click="
|
||||||
() => {
|
() => {
|
||||||
showHelpModal = minimize ? true : !showHelpModal
|
showHelpModal = minimize ? true : !showHelpModal
|
||||||
minimize = !showHelpModal
|
minimize = !showHelpModal
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Tooltip
|
||||||
|
:text="
|
||||||
|
sidebarStore.isSidebarCollapsed ? __('Expand') : __('Collapse')
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<template #icon>
|
|
||||||
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
|
||||||
<CircleHelp class="h-4 w-4 stroke-1.5" />
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</SidebarLink>
|
|
||||||
<SidebarLink
|
|
||||||
:link="{
|
|
||||||
label: sidebarStore.isSidebarCollapsed ? 'Expand' : 'Collapse',
|
|
||||||
}"
|
|
||||||
:isCollapsed="sidebarStore.isSidebarCollapsed"
|
|
||||||
@click="toggleSidebar()"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
|
||||||
<CollapseSidebar
|
<CollapseSidebar
|
||||||
class="h-4 w-4 text-ink-gray-7 duration-300 ease-in-out"
|
class="size-4 text-ink-gray-7 duration-300 stroke-1.5 ease-in-out cursor-pointer"
|
||||||
:class="{
|
:class="{
|
||||||
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed,
|
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed,
|
||||||
}"
|
}"
|
||||||
|
@click="toggleSidebar()"
|
||||||
/>
|
/>
|
||||||
</span>
|
</Tooltip>
|
||||||
</template>
|
</div>
|
||||||
</SidebarLink>
|
|
||||||
</div>
|
</div>
|
||||||
<HelpModal
|
<HelpModal
|
||||||
v-if="showOnboarding && showHelpModal"
|
v-if="showOnboarding && showHelpModal"
|
||||||
@@ -141,14 +181,22 @@
|
|||||||
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'
|
||||||
import { useSettings } from '@/stores/settings'
|
import { useSettings } from '@/stores/settings'
|
||||||
import { Button, createResource } from 'frappe-ui'
|
import { Button, createResource, Tooltip } from 'frappe-ui'
|
||||||
import PageModal from '@/components/Modals/PageModal.vue'
|
import PageModal from '@/components/Modals/PageModal.vue'
|
||||||
import { capture } from '@/telemetry'
|
import { capture } from '@/telemetry'
|
||||||
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
||||||
@@ -156,6 +204,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import InviteIcon from './Icons/InviteIcon.vue'
|
import InviteIcon from './Icons/InviteIcon.vue'
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
CircleAlert,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Plus,
|
Plus,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
@@ -164,6 +213,7 @@ import {
|
|||||||
UserPlus,
|
UserPlus,
|
||||||
Users,
|
Users,
|
||||||
BookText,
|
BookText,
|
||||||
|
Zap,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
TrialBanner,
|
TrialBanner,
|
||||||
@@ -175,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')
|
||||||
@@ -186,12 +236,14 @@ 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({})
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
let onboardingDetails
|
let onboardingDetails
|
||||||
let isOnboardingStepsCompleted = false
|
let isOnboardingStepsCompleted = false
|
||||||
|
const readOnlyMode = window.read_only_mode
|
||||||
const iconProps = {
|
const iconProps = {
|
||||||
strokeWidth: 1.5,
|
strokeWidth: 1.5,
|
||||||
width: 16,
|
width: 16,
|
||||||
@@ -201,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()
|
||||||
})
|
})
|
||||||
@@ -261,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',
|
||||||
@@ -277,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',
|
||||||
@@ -291,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
|
||||||
@@ -345,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(
|
||||||
@@ -395,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()
|
||||||
@@ -410,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()
|
||||||
@@ -428,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' })
|
||||||
@@ -459,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()
|
||||||
@@ -479,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()
|
||||||
@@ -573,9 +643,18 @@ 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()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const redirectToWebsite = () => {
|
||||||
|
window.open('https://frappe.io/learning', '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
socket.off('publish_lms_notifications')
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<template #target="{ togglePopover }">
|
<template #target="{ togglePopover }">
|
||||||
<button
|
<button
|
||||||
:class="[
|
:class="[
|
||||||
'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-ink-gray-8 hover:bg-surface-gray-2',
|
'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-ink-gray-7 hover:bg-surface-gray-2',
|
||||||
]"
|
]"
|
||||||
@click.prevent="togglePopover()"
|
@click.prevent="togglePopover()"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="text-lg font-semibold text-ink-gray-9">
|
<div class="text-lg font-semibold text-ink-gray-9">
|
||||||
{{ __('Assessments') }}
|
{{ __('Assessments') }}
|
||||||
</div>
|
</div>
|
||||||
<Button v-if="canSeeAddButton()" @click="showModal = true">
|
<Button v-if="canAddAssessments()" @click="showModal = true">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus class="h-4 w-4" />
|
<Plus class="h-4 w-4" />
|
||||||
</template>
|
</template>
|
||||||
@@ -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] }}
|
||||||
@@ -100,6 +100,7 @@ import { Plus, Trash2 } from 'lucide-vue-next'
|
|||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const showModal = ref(false)
|
const showModal = ref(false)
|
||||||
|
const readOnlyMode = window.read_only_mode
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
@@ -171,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',
|
||||||
@@ -181,7 +200,8 @@ const getRowRoute = (row) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const canSeeAddButton = () => {
|
const canAddAssessments = () => {
|
||||||
|
if (readOnlyMode) return false
|
||||||
return user.data?.is_moderator || user.data?.is_evaluator
|
return user.data?.is_moderator || user.data?.is_evaluator
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,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'
|
||||||
@@ -219,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>
|
||||||
|
|||||||
@@ -191,10 +191,11 @@ import {
|
|||||||
FileUploader,
|
FileUploader,
|
||||||
FormControl,
|
FormControl,
|
||||||
TextEditor,
|
TextEditor,
|
||||||
|
toast,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import { FileText, X } from 'lucide-vue-next'
|
import { FileText, X } from 'lucide-vue-next'
|
||||||
import { showToast, getFileSize } from '@/utils'
|
import { getFileSize } from '@/utils'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const submissionFile = ref(null)
|
const submissionFile = ref(null)
|
||||||
@@ -284,7 +285,7 @@ const submissionResource = createDocumentResource({
|
|||||||
doctype: 'LMS Assignment Submission',
|
doctype: 'LMS Assignment Submission',
|
||||||
name: props.submissionName,
|
name: props.submissionName,
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
toast.error(err.messages?.[0] || err)
|
||||||
},
|
},
|
||||||
auto: false,
|
auto: false,
|
||||||
cache: [user.data?.name, props.assignmentID],
|
cache: [user.data?.name, props.assignmentID],
|
||||||
@@ -338,7 +339,7 @@ const submitAssignment = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
showToast(__('Success'), __('Changes saved successfully'), 'check')
|
toast.success(__('Changes saved successfully'))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -352,7 +353,7 @@ const addNewSubmission = () => {
|
|||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
showToast('Success', 'Assignment submitted successfully.', 'check')
|
toast.success(__('Assignment submitted successfully'))
|
||||||
if (router.currentRoute.value.name == 'AssignmentSubmission') {
|
if (router.currentRoute.value.name == 'AssignmentSubmission') {
|
||||||
router.push({
|
router.push({
|
||||||
name: 'AssignmentSubmission',
|
name: 'AssignmentSubmission',
|
||||||
@@ -370,7 +371,7 @@ const addNewSubmission = () => {
|
|||||||
submissionResource.reload()
|
submissionResource.reload()
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast('Error', err.messages?.[0] || err, 'x')
|
toast.error(err.messages?.[0] || err)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col border-2 hover:bg-surface-gray-2 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'
|
||||||
|
|||||||
@@ -86,9 +86,10 @@ import {
|
|||||||
ListRows,
|
ListRows,
|
||||||
ListView,
|
ListView,
|
||||||
ListRowItem,
|
ListRowItem,
|
||||||
|
toast,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||||
import { showToast } from '@/utils'
|
const readOnlyMode = window.read_only_mode
|
||||||
|
|
||||||
const showCourseModal = ref(false)
|
const showCourseModal = ref(false)
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
@@ -105,7 +106,6 @@ const courses = createResource({
|
|||||||
params: {
|
params: {
|
||||||
batch: props.batch,
|
batch: props.batch,
|
||||||
},
|
},
|
||||||
cache: ['batchCourses', props.batchName],
|
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ const removeCourses = (selections, unselectAll) => {
|
|||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
courses.reload()
|
courses.reload()
|
||||||
showToast(__('Success'), __('Courses deleted successfully'), 'check')
|
toast.success(__('Courses deleted successfully'))
|
||||||
unselectAll()
|
unselectAll()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -159,6 +159,9 @@ const removeCourses = (selections, unselectAll) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canSeeAddButton = () => {
|
const canSeeAddButton = () => {
|
||||||
|
if (readOnlyMode) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return user.data?.is_moderator || user.data?.is_evaluator
|
return user.data?.is_moderator || user.data?.is_evaluator
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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,82 +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,
|
|
||||||
ListHeaderItem,
|
|
||||||
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: {
|
||||||
@@ -168,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
|
||||||
@@ -202,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 {
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72">
|
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72">
|
||||||
<div
|
<div
|
||||||
v-if="batch.data.seat_count && seats_left > 0"
|
v-if="batch.data.seat_count && seats_left > 0"
|
||||||
class="text-xs bg-green-100 text-green-700 float-right px-2 py-0.5 rounded-md"
|
class="text-sm bg-green-100 text-green-700 px-2 py-1 rounded-md"
|
||||||
|
:class="
|
||||||
|
batch.data.amount || batch.data.courses.length
|
||||||
|
? 'float-right'
|
||||||
|
: 'w-fit mb-4'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{ seats_left }}
|
{{ seats_left }}
|
||||||
<span v-if="seats_left > 1">
|
<span v-if="seats_left > 1">
|
||||||
@@ -24,7 +29,10 @@
|
|||||||
>
|
>
|
||||||
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
|
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center mb-3 text-ink-gray-7">
|
<div
|
||||||
|
v-if="batch.data.courses.length"
|
||||||
|
class="flex items-center mb-3 text-ink-gray-7"
|
||||||
|
>
|
||||||
<BookOpen class="h-4 w-4 stroke-1.5 mr-2" />
|
<BookOpen class="h-4 w-4 stroke-1.5 mr-2" />
|
||||||
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
|
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,6 +54,7 @@
|
|||||||
{{ batch.data.timezone }}
|
{{ batch.data.timezone }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="!readOnlyMode">
|
||||||
<router-link
|
<router-link
|
||||||
v-if="isModerator || isStudent"
|
v-if="isModerator || isStudent"
|
||||||
:to="{
|
:to="{
|
||||||
@@ -56,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>
|
||||||
@@ -76,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>
|
||||||
@@ -91,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
|
||||||
@@ -103,24 +122,37 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<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>
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { inject, computed } from 'vue'
|
import { inject, computed } from 'vue'
|
||||||
import { Badge, Button, createResource } from 'frappe-ui'
|
import { Button, createResource, toast } from 'frappe-ui'
|
||||||
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
|
import {
|
||||||
import { formatNumberIntoCurrency, formatTime, showToast } from '@/utils'
|
BookOpen,
|
||||||
|
Clock,
|
||||||
|
CreditCard,
|
||||||
|
Globe,
|
||||||
|
GraduationCap,
|
||||||
|
LogIn,
|
||||||
|
Pencil,
|
||||||
|
Settings,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { formatNumberIntoCurrency, formatTime } from '@/utils'
|
||||||
import DateRange from '@/components/Common/DateRange.vue'
|
import DateRange from '@/components/Common/DateRange.vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const dayjs = inject('$dayjs')
|
const readOnlyMode = window.read_only_mode
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
@@ -146,11 +178,7 @@ const enrollInBatch = () => {
|
|||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
showToast(
|
toast.success(__('You have been enrolled in this batch'))
|
||||||
__('Success'),
|
|
||||||
__('You have been enrolled in this batch'),
|
|
||||||
'check'
|
|
||||||
)
|
|
||||||
router.push({
|
router.push({
|
||||||
name: 'Batch',
|
name: 'Batch',
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
@@ -1,108 +1,64 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="">
|
<div v-if="batch.data" class="">
|
||||||
<div class="w-full flex items-center justify-between pb-4">
|
<div class="w-full flex items-center justify-between pb-4">
|
||||||
<div class="font-medium text-ink-gray-7">
|
<div class="font-medium text-ink-gray-7">
|
||||||
{{ __('Statistics') }}
|
{{ __('Statistics') }}
|
||||||
</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.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>
|
||||||
@@ -110,7 +66,7 @@
|
|||||||
<div class="text-ink-gray-7 font-medium">
|
<div class="text-ink-gray-7 font-medium">
|
||||||
{{ __('Students') }}
|
{{ __('Students') }}
|
||||||
</div>
|
</div>
|
||||||
<Button @click="openStudentModal()">
|
<Button v-if="!readOnlyMode" @click="openStudentModal()">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus class="h-4 w-4" />
|
<Plus class="h-4 w-4" />
|
||||||
</template>
|
</template>
|
||||||
@@ -201,9 +157,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StudentModal
|
<StudentModal
|
||||||
:batch="props.batch.name"
|
:batch="props.batch.data.name"
|
||||||
v-model="showStudentModal"
|
v-model="showStudentModal"
|
||||||
v-model:reloadStudents="students"
|
v-model:reloadStudents="students"
|
||||||
|
v-model:batchModal="props.batch"
|
||||||
/>
|
/>
|
||||||
<BatchStudentProgress
|
<BatchStudentProgress
|
||||||
:student="selectedStudent"
|
:student="selectedStudent"
|
||||||
@@ -213,6 +170,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
|
AxisChart,
|
||||||
Button,
|
Button,
|
||||||
createResource,
|
createResource,
|
||||||
FeatherIcon,
|
FeatherIcon,
|
||||||
@@ -223,6 +181,8 @@ import {
|
|||||||
ListRows,
|
ListRows,
|
||||||
ListView,
|
ListView,
|
||||||
ListRowItem,
|
ListRowItem,
|
||||||
|
NumberChart,
|
||||||
|
toast,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
@@ -234,7 +194,6 @@ import {
|
|||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import StudentModal from '@/components/Modals/StudentModal.vue'
|
import StudentModal from '@/components/Modals/StudentModal.vue'
|
||||||
import { showToast } from '@/utils'
|
|
||||||
import ProgressBar from '@/components/ProgressBar.vue'
|
import ProgressBar from '@/components/ProgressBar.vue'
|
||||||
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
|
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
|
||||||
import ApexChart from 'vue3-apexcharts'
|
import ApexChart from 'vue3-apexcharts'
|
||||||
@@ -244,9 +203,9 @@ 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 props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
@@ -257,15 +216,15 @@ const props = defineProps({
|
|||||||
|
|
||||||
const students = createResource({
|
const students = createResource({
|
||||||
url: 'lms.lms.utils.get_batch_students',
|
url: 'lms.lms.utils.get_batch_students',
|
||||||
cache: ['students', props.batch.name],
|
|
||||||
params: {
|
params: {
|
||||||
batch: props.batch?.name,
|
batch: props.batch?.data?.name,
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
chartData.value = getChartData()
|
chartData.value = getChartData()
|
||||||
showProgressChart.value =
|
showProgressChart.value =
|
||||||
data.length && (props.batch?.courses?.length || assessmentCount.value)
|
data.length &&
|
||||||
|
(props.batch?.data?.courses?.length || assessmentCount.value)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -322,7 +281,8 @@ const removeStudents = (selections, unselectAll) => {
|
|||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
students.reload()
|
students.reload()
|
||||||
showToast(__('Success'), __('Students deleted successfully'), 'check')
|
props.batch.reload()
|
||||||
|
toast.success(__('Students deleted successfully'))
|
||||||
unselectAll()
|
unselectAll()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -330,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, () => {
|
||||||
@@ -433,14 +346,9 @@ const certificationCount = createResource({
|
|||||||
params: {
|
params: {
|
||||||
doctype: 'LMS Certificate',
|
doctype: 'LMS Certificate',
|
||||||
filters: {
|
filters: {
|
||||||
batch_name: props.batch.name,
|
batch_name: props.batch?.data?.name,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
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 #icon>
|
|
||||||
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
|
|
||||||
<X v-else class="h-3 w-3 stroke-1.5" />
|
|
||||||
</template>
|
|
||||||
</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 divide-y space-y-2">
|
|
||||||
<FormControl
|
|
||||||
:value="cat.category"
|
|
||||||
type="text"
|
|
||||||
v-for="cat in categories.data"
|
|
||||||
class=""
|
|
||||||
@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" />
|
||||||
@@ -29,7 +39,7 @@
|
|||||||
<template #body="{ isOpen }">
|
<template #body="{ isOpen }">
|
||||||
<div v-show="isOpen">
|
<div v-show="isOpen">
|
||||||
<div
|
<div
|
||||||
class="mt-1 rounded-lg bg-surface-white py-1 text-base shadow-2xl"
|
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
|
||||||
@@ -49,7 +59,7 @@
|
|||||||
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
|
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
|
||||||
@click="selectedValue = null"
|
@click="selectedValue = null"
|
||||||
>
|
>
|
||||||
<X class="h-4 w-4 stroke-1.5" />
|
<X class="h-4 w-4 stroke-1.5 text-ink-gray-7" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ComboboxOptions
|
<ComboboxOptions
|
||||||
@@ -89,12 +99,15 @@
|
|||||||
name="item-label"
|
name="item-label"
|
||||||
v-bind="{ active, selected, option }"
|
v-bind="{ active, selected, option }"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col space-y-1">
|
<div class="flex flex-col space-y-1 text-ink-gray-8">
|
||||||
<div>
|
<div>
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="option.description"
|
v-if="
|
||||||
|
option.description &&
|
||||||
|
option.description != option.label
|
||||||
|
"
|
||||||
class="text-xs text-ink-gray-7"
|
class="text-xs text-ink-gray-7"
|
||||||
v-html="option.description"
|
v-html="option.description"
|
||||||
></div>
|
></div>
|
||||||
@@ -121,6 +134,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</Popover>
|
</Popover>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -147,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 }" />
|
||||||
@@ -34,7 +35,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="w-full !justify-start"
|
class="w-full !justify-start"
|
||||||
label="Create New"
|
:label="__('Create New')"
|
||||||
@click="attrs.onCreate(value, close)"
|
@click="attrs.onCreate(value, close)"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
|
|||||||
@@ -4,22 +4,7 @@
|
|||||||
{{ label }}
|
{{ label }}
|
||||||
<span class="text-ink-red-3" v-if="required">*</span>
|
<span class="text-ink-red-3" v-if="required">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="grid grid-cols-3 gap-1">
|
<div class="w-full">
|
||||||
<Button
|
|
||||||
ref="emails"
|
|
||||||
v-for="value in values"
|
|
||||||
:key="value"
|
|
||||||
:label="value"
|
|
||||||
theme="gray"
|
|
||||||
variant="subtle"
|
|
||||||
class="rounded-md"
|
|
||||||
@keydown.delete.capture.stop="removeLastValue"
|
|
||||||
>
|
|
||||||
<template #suffix>
|
|
||||||
<X @click="removeValue(value)" class="h-4 w-4 stroke-1.5" />
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
<div class="">
|
|
||||||
<Combobox v-model="selectedValue" nullable>
|
<Combobox v-model="selectedValue" nullable>
|
||||||
<Popover class="w-full" v-model:show="showOptions">
|
<Popover class="w-full" v-model:show="showOptions">
|
||||||
<template #target="{ togglePopover }">
|
<template #target="{ togglePopover }">
|
||||||
@@ -39,13 +24,13 @@
|
|||||||
@keydown.delete.capture.stop="removeLastValue"
|
@keydown.delete.capture.stop="removeLastValue"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #body="{ isOpen }">
|
<template #body="{ isOpen, close }">
|
||||||
<div v-show="isOpen">
|
<div v-show="isOpen">
|
||||||
<div
|
<div
|
||||||
class="mt-1 rounded-lg bg-surface-white py-1 text-base shadow-2xl"
|
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
|
||||||
>
|
>
|
||||||
<ComboboxOptions
|
<ComboboxOptions
|
||||||
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
|
class="my-1 min-h-[6rem] max-h-[12rem] overflow-y-auto px-1.5"
|
||||||
static
|
static
|
||||||
>
|
>
|
||||||
<ComboboxOption
|
<ComboboxOption
|
||||||
@@ -61,7 +46,7 @@
|
|||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-1 p-1">
|
<div class="flex flex-col gap-1 p-1">
|
||||||
<div class="text-base font-medium">
|
<div class="text-base font-medium text-ink-gray-8">
|
||||||
{{ option.description }}
|
{{ option.description }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-ink-gray-5">
|
<div class="text-sm text-ink-gray-5">
|
||||||
@@ -70,6 +55,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ComboboxOption>
|
</ComboboxOption>
|
||||||
|
<div class="h-10"></div>
|
||||||
|
<div
|
||||||
|
v-if="attrs.onCreate"
|
||||||
|
class="absolute bottom-2 left-1 w-[99%] pt-2 bg-white border-t"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="w-full !justify-start"
|
||||||
|
:label="__('Create New')"
|
||||||
|
@click="attrs.onCreate(close)"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="h-4 w-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</ComboboxOptions>
|
</ComboboxOptions>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,6 +78,19 @@
|
|||||||
</Popover>
|
</Popover>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="values.length" class="grid grid-cols-2 gap-2 mt-1">
|
||||||
|
<div
|
||||||
|
v-for="value in values"
|
||||||
|
class="flex items-center justify-between break-all bg-surface-gray-2 text-ink-gray-7 word-wrap p-2 rounded-md mr-2"
|
||||||
|
>
|
||||||
|
<span class="break-all">
|
||||||
|
{{ value }}
|
||||||
|
</span>
|
||||||
|
<X
|
||||||
|
class="size-4 stroke-1.5 cursor-pointer"
|
||||||
|
@click="removeValue(value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> -->
|
<!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> -->
|
||||||
</div>
|
</div>
|
||||||
@@ -90,9 +104,9 @@ import {
|
|||||||
ComboboxOption,
|
ComboboxOption,
|
||||||
} from '@headlessui/vue'
|
} from '@headlessui/vue'
|
||||||
import { createResource, Popover, Button } from 'frappe-ui'
|
import { createResource, Popover, Button } from 'frappe-ui'
|
||||||
import { ref, computed, nextTick } from 'vue'
|
import { ref, computed, nextTick, useAttrs } from 'vue'
|
||||||
import { watchDebounced } from '@vueuse/core'
|
import { watchDebounced } from '@vueuse/core'
|
||||||
import { X } from 'lucide-vue-next'
|
import { X, Plus } from 'lucide-vue-next'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
label: {
|
label: {
|
||||||
@@ -124,7 +138,7 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const values = defineModel()
|
const values = defineModel()
|
||||||
|
const attrs = useAttrs()
|
||||||
const emails = ref([])
|
const emails = ref([])
|
||||||
const search = ref(null)
|
const search = ref(null)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
|
|||||||
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,37 +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
|
<div
|
||||||
class="flex items-center flex-wrap space-x-1 relative top-4 px-2 w-fit"
|
v-if="course.featured"
|
||||||
|
class="flex items-center space-x-1 text-xs text-ink-amber-3 bg-surface-white border border-outline-amber-1 px-2 py-0.5 rounded-md mr-1 mb-1"
|
||||||
>
|
>
|
||||||
<Badge v-if="course.featured" variant="subtle" theme="green" size="md">
|
<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"
|
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"
|
||||||
</div>
|
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 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>
|
||||||
@@ -40,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>
|
||||||
@@ -49,29 +63,22 @@
|
|||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
<div v-if="course.status != 'Approved'">
|
<div
|
||||||
<Badge
|
class="font-semibold leading-6"
|
||||||
variant="subtle"
|
:class="course.title.length > 32 ? 'text-lg' : 'text-xl'"
|
||||||
:theme="course.status === 'Under Review' ? 'orange' : 'blue'"
|
|
||||||
size="sm"
|
|
||||||
>
|
>
|
||||||
{{ course.status }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-xl font-semibold leading-6 text-ink-gray-9">
|
|
||||||
{{ 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>
|
||||||
|
|
||||||
@@ -80,11 +87,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">
|
||||||
@@ -104,21 +108,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'
|
||||||
|
|
||||||
@@ -130,16 +136,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;
|
||||||
@@ -153,14 +167,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;
|
||||||
@@ -169,14 +175,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"
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
<div v-if="course.data.paid_course" class="text-2xl font-semibold mb-3">
|
<div v-if="course.data.paid_course" class="text-2xl font-semibold mb-3">
|
||||||
{{ course.data.price }}
|
{{ course.data.price }}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="!readOnlyMode">
|
||||||
<div v-if="course.data.membership" class="space-y-2">
|
<div v-if="course.data.membership" class="space-y-2">
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
@@ -25,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>
|
||||||
@@ -43,24 +47,31 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<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>
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<div
|
<Badge
|
||||||
v-else-if="course.data.disable_self_learning"
|
v-else-if="course.data.disable_self_learning"
|
||||||
class="bg-surface-blue-2 text-blue-900 text-sm rounded-md py-1 px-3"
|
theme="blue"
|
||||||
|
size="lg"
|
||||||
>
|
>
|
||||||
{{ __('Contact the Administrator to enroll for this course.') }}
|
{{ __('Contact the Administrator to enroll for this course.') }}
|
||||||
</div>
|
</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>
|
||||||
@@ -72,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="{
|
||||||
@@ -84,13 +109,20 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<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>
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
</div>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="mt-8 font-medium text-ink-gray-9">
|
<div
|
||||||
|
class="font-medium text-ink-gray-9"
|
||||||
|
:class="{ 'mt-8': !readOnlyMode }"
|
||||||
|
>
|
||||||
{{ __('This course has:') }}
|
{{ __('This course has:') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center text-ink-gray-9">
|
<div class="flex items-center text-ink-gray-9">
|
||||||
@@ -110,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>
|
||||||
@@ -136,18 +168,35 @@
|
|||||||
</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 { Button, createResource, Tooltip } from 'frappe-ui'
|
BookText,
|
||||||
import { showToast, formatAmount } from '@/utils/'
|
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 { 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 props = defineProps({
|
const props = defineProps({
|
||||||
course: {
|
course: {
|
||||||
@@ -165,31 +214,19 @@ const video_link = computed(() => {
|
|||||||
|
|
||||||
function enrollStudent() {
|
function enrollStudent() {
|
||||||
if (!user.data) {
|
if (!user.data) {
|
||||||
showToast(
|
toast.success(__('You need to login first to enroll for this course'))
|
||||||
__('Please Login'),
|
|
||||||
__('You need to login first to enroll for this course'),
|
|
||||||
'alert-circle'
|
|
||||||
)
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
||||||
}, 2000)
|
}, 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(() => {
|
||||||
capture('enrolled_in_course', {
|
capture('enrolled_in_course', {
|
||||||
course: props.course.data.name,
|
course: props.course.data.name,
|
||||||
})
|
})
|
||||||
showToast(
|
toast.success(__('You have been enrolled in this course'))
|
||||||
__('Success'),
|
|
||||||
__('You have been enrolled in this course'),
|
|
||||||
'check'
|
|
||||||
)
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push({
|
router.push({
|
||||||
name: 'Lesson',
|
name: 'Lesson',
|
||||||
@@ -199,7 +236,11 @@ function enrollStudent() {
|
|||||||
lessonNumber: 1,
|
lessonNumber: 1,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, 2000)
|
}, 1000)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
|
console.error(err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,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>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full">
|
<div class="">
|
||||||
<div
|
<div
|
||||||
v-if="title && (outline.data?.length || allowEdit)"
|
v-if="title && (outline.data?.length || allowEdit)"
|
||||||
class="flex items-center justify-between space-x-2 mb-4 px-2"
|
class="flex items-center justify-between space-x-2 mb-4 px-2"
|
||||||
@@ -17,22 +17,30 @@
|
|||||||
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
|
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
|
||||||
{{ __('Add Chapter') }}
|
{{ __('Add Chapter') }}
|
||||||
</Button>
|
</Button>
|
||||||
<!-- <span class="font-medium cursor-pointer" @click="expandAllChapters()">
|
|
||||||
{{ expandAll ? __("Collapse all chapters") : __("Expand all chapters") }}
|
|
||||||
</span> -->
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
:class="{
|
:class="{
|
||||||
'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,
|
||||||
@@ -108,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
|
||||||
@@ -140,6 +150,9 @@
|
|||||||
</DisclosurePanel>
|
</DisclosurePanel>
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</Draggable>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChapterModal
|
<ChapterModal
|
||||||
v-if="user.data"
|
v-if="user.data"
|
||||||
@@ -150,8 +163,8 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Button, createResource, Tooltip } 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 {
|
||||||
@@ -165,7 +178,6 @@ import {
|
|||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import ChapterModal from '@/components/Modals/ChapterModal.vue'
|
import ChapterModal from '@/components/Modals/ChapterModal.vue'
|
||||||
import { showToast } from '@/utils'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -201,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) {
|
||||||
@@ -218,7 +239,7 @@ const deleteLesson = createResource({
|
|||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
outline.reload()
|
outline.reload()
|
||||||
showToast('Success', 'Lesson deleted successfully', 'check')
|
toast.success(__('Lesson deleted successfully'))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -233,7 +254,21 @@ const updateLessonIndex = createResource({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
showToast('Success', 'Lesson moved successfully', 'check')
|
toast.success(__('Lesson moved successfully'))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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'))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -282,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) {
|
||||||
@@ -291,7 +334,7 @@ const deleteChapter = createResource({
|
|||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
outline.reload()
|
outline.reload()
|
||||||
showToast('Success', 'Chapter deleted successfully', 'check')
|
toast.success(__('Chapter deleted successfully'))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -320,11 +363,7 @@ const redirectToChapter = (chapter) => {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (props.allowEdit) return
|
if (props.allowEdit) return
|
||||||
if (!user.data) {
|
if (!user.data) {
|
||||||
showToast(
|
toast.success(__('Please enroll for this course to view this lesson'))
|
||||||
__('You are not enrolled'),
|
|
||||||
__('Please enroll for this course to view this lesson'),
|
|
||||||
'alert-circle'
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -27,16 +27,18 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
v-if="user.data.name == reply.owner && !reply.editable"
|
v-if="
|
||||||
|
user.data.name == reply.owner && !reply.editable && !readOnlyMode
|
||||||
|
"
|
||||||
:options="[
|
:options="[
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: __('Edit'),
|
||||||
onClick() {
|
onClick() {
|
||||||
reply.editable = true
|
reply.editable = true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Delete',
|
label: __('Delete'),
|
||||||
onClick() {
|
onClick() {
|
||||||
deleteReply(reply)
|
deleteReply(reply)
|
||||||
},
|
},
|
||||||
@@ -71,7 +73,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TextEditor
|
<TextEditor
|
||||||
v-if="renderEditor"
|
v-if="renderEditor && !readOnlyMode"
|
||||||
class="mt-5"
|
class="mt-5"
|
||||||
:content="newReply"
|
:content="newReply"
|
||||||
:mentions="mentionUsers"
|
:mentions="mentionUsers"
|
||||||
@@ -80,7 +82,7 @@
|
|||||||
:fixedMenu="true"
|
:fixedMenu="true"
|
||||||
editorClass="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 border border-outline-gray-2 rounded-b-md min-h-[7rem] py-1 px-2"
|
editorClass="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 border border-outline-gray-2 rounded-b-md min-h-[7rem] py-1 px-2"
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-between mt-2">
|
<div v-if="!readOnlyMode" class="flex justify-between mt-2">
|
||||||
<span> </span>
|
<span> </span>
|
||||||
<Button @click="postReply()">
|
<Button @click="postReply()">
|
||||||
<span>
|
<span>
|
||||||
@@ -91,12 +93,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createResource, TextEditor, Button, Dropdown } 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'
|
||||||
import { createToast } from '../utils'
|
|
||||||
|
|
||||||
const showTopics = defineModel('showTopics')
|
const showTopics = defineModel('showTopics')
|
||||||
const newReply = ref('')
|
const newReply = ref('')
|
||||||
@@ -105,6 +106,7 @@ const user = inject('$user')
|
|||||||
const allUsers = inject('$allUsers')
|
const allUsers = inject('$allUsers')
|
||||||
const mentionUsers = ref([])
|
const mentionUsers = ref([])
|
||||||
const renderEditor = ref(false)
|
const renderEditor = ref(false)
|
||||||
|
const readOnlyMode = window.read_only_mode
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
topic: {
|
topic: {
|
||||||
@@ -189,14 +191,7 @@ const postReply = () => {
|
|||||||
replies.reload()
|
replies.reload()
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
createToast({
|
toast.error(err.messages?.[0] || err)
|
||||||
title: 'Error',
|
|
||||||
text: err.messages?.[0] || err,
|
|
||||||
icon: 'x',
|
|
||||||
iconClasses: 'bg-surface-red-5 text-ink-white rounded-md p-px',
|
|
||||||
position: 'top-center',
|
|
||||||
timeout: 10,
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -256,4 +251,10 @@ const deleteReply = (reply) => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
socket.off('publish_message')
|
||||||
|
socket.off('update_message')
|
||||||
|
socket.off('delete_message')
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<Button v-if="!singleThread" class="float-right" @click="openTopicModal()">
|
<Button
|
||||||
|
v-if="!singleThread && !readOnlyMode"
|
||||||
|
class="float-right"
|
||||||
|
@click="openTopicModal()"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="size-4" />
|
||||||
|
</template>
|
||||||
{{ __('New {0}').format(singularize(title)) }}
|
{{ __('New {0}').format(singularize(title)) }}
|
||||||
</Button>
|
</Button>
|
||||||
<div class="text-xl font-semibold text-ink-gray-9">
|
<div class="text-xl font-semibold text-ink-gray-9">
|
||||||
@@ -45,7 +52,7 @@
|
|||||||
class="flex flex-col items-center justify-center border-2 border-dashed mt-5 py-8 rounded-md"
|
class="flex flex-col items-center justify-center border-2 border-dashed mt-5 py-8 rounded-md"
|
||||||
>
|
>
|
||||||
<MessageSquareText class="w-7 h-7 text-ink-gray-4 stroke-1.5 mr-2" />
|
<MessageSquareText class="w-7 h-7 text-ink-gray-4 stroke-1.5 mr-2" />
|
||||||
<div class="">
|
<div class="mt-2">
|
||||||
<div v-if="emptyStateTitle" class="font-medium mb-2">
|
<div v-if="emptyStateTitle" class="font-medium mb-2">
|
||||||
{{ __(emptyStateTitle) }}
|
{{ __(emptyStateTitle) }}
|
||||||
</div>
|
</div>
|
||||||
@@ -65,11 +72,11 @@
|
|||||||
<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, Plus } from 'lucide-vue-next'
|
||||||
import { getScrollContainer } from '@/utils/scrollContainer'
|
import { getScrollContainer } from '@/utils/scrollContainer'
|
||||||
|
|
||||||
const showTopics = ref(true)
|
const showTopics = ref(true)
|
||||||
@@ -77,6 +84,7 @@ const currentTopic = ref(null)
|
|||||||
const socket = inject('$socket')
|
const socket = inject('$socket')
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const showTopicModal = ref(false)
|
const showTopicModal = ref(false)
|
||||||
|
const readOnlyMode = window.read_only_mode
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
title: {
|
title: {
|
||||||
@@ -97,7 +105,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
emptyStateText: {
|
emptyStateText: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'Start a discussion',
|
default: 'Start a Discussion',
|
||||||
},
|
},
|
||||||
singleThread: {
|
singleThread: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -148,4 +156,8 @@ const showReplies = (topic) => {
|
|||||||
const openTopicModal = () => {
|
const openTopicModal = () => {
|
||||||
showTopicModal.value = true
|
showTopicModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
socket.off('new_discussion_topic')
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
24
frontend/src/components/EmptyState.vue
Normal file
24
frontend/src/components/EmptyState.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-center justify-center mt-60">
|
||||||
|
<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 {0}').format(type?.toLowerCase()) }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="leading-5 text-base w-2/5 text-base text-center text-ink-gray-7"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'There are no {0} currently. Keep an eye out, fresh learning experiences are on the way!'
|
||||||
|
).format(type?.toLowerCase())
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { BookOpen, GraduationCap } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
type: String,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,129 +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 #icon>
|
|
||||||
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
|
|
||||||
<X v-else class="h-3 w-3 stroke-1.5" />
|
|
||||||
</template>
|
|
||||||
</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>
|
|
||||||
16
frontend/src/components/Icons/Play.vue
Normal file
16
frontend/src/components/Icons/Play.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 68 75"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M0 6.78182C0 1.60212 5.5742 -1.65958 10.09 0.879521L64.09 31.2545C68.6916 33.8443 68.6916 40.4693 64.09 43.0595L10.09 73.4345C5.5744 75.9736 0 72.7119 0 67.5322V6.78182ZM26.2695 38.5201C26.2695 37.3248 25.2265 37.9342 26.2695 38.5201C27.332 39.1178 27.332 37.9225 26.2695 38.5201Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@@ -1,25 +1,35 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="border rounded-md p-4">
|
<div
|
||||||
<div class="flex space-x-4">
|
class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-3"
|
||||||
<img
|
>
|
||||||
:src="job.company_logo"
|
<div class="flex space-x-4 mb-4">
|
||||||
class="size-10 rounded-full object-contain"
|
<div class="flex flex-col space-y-2 flex-1">
|
||||||
/>
|
<div class="text-lg font-semibold text-ink-gray-9">
|
||||||
<div class="flex flex-col space-y-1 flex-1">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-lg font-semibold text-ink-gray-9">
|
|
||||||
{{ job.job_title }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-ink-gray-5">
|
|
||||||
{{ job.company_name }}
|
{{ job.company_name }}
|
||||||
</div>
|
</div>
|
||||||
|
<span class="font-medium text-ink-gray-7 leading-5">
|
||||||
|
{{ job.job_title }}
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center space-x-1 text-sm text-ink-gray-7">
|
||||||
|
<MapPin class="size-3" />
|
||||||
|
<span>
|
||||||
|
{{ job.location }}{{ job.country ? `, ${job.country}` : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="job.applicants"
|
||||||
|
class="flex items-center space-x-1 text-sm text-ink-gray-7"
|
||||||
|
>
|
||||||
|
<User class="size-3" />
|
||||||
|
<span>
|
||||||
|
{{ job.applicants }}
|
||||||
|
{{ job.applicants > 1 ? __('applicants') : __('applicant') }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-x-4 mt-2">
|
<!-- <img :src="job.company_logo" alt="Company Logo" class="size-8 rounded-full object-contain bg-white" /> -->
|
||||||
<Badge>
|
</div>
|
||||||
{{ job.location }}
|
<div class="space-x-2 mt-auto">
|
||||||
</Badge>
|
|
||||||
<Badge>
|
<Badge>
|
||||||
{{ job.type }}
|
{{ job.type }}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -27,11 +37,16 @@
|
|||||||
{{ dayjs(job.creation).fromNow() }}
|
{{ dayjs(job.creation).fromNow() }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- <div
|
||||||
|
class="description text-ink-gray-9 text-sm"
|
||||||
|
v-html="job.description"
|
||||||
|
></div> -->
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { inject } from 'vue'
|
import { inject } from 'vue'
|
||||||
import { Badge } from 'frappe-ui'
|
import { Badge } from 'frappe-ui'
|
||||||
|
import { MapPin, User } from 'lucide-vue-next'
|
||||||
|
|
||||||
const dayjs = inject('$dayjs')
|
const dayjs = inject('$dayjs')
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -41,3 +56,15 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
<style>
|
||||||
|
.description {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: auto;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -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,9 +1,19 @@
|
|||||||
<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>
|
||||||
<Button v-if="user.data.is_moderator" @click="openLiveClassModal">
|
<Button v-if="canCreateClass()" @click="openLiveClassModal">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus class="h-4 w-4" />
|
<Plus class="h-4 w-4" />
|
||||||
</template>
|
</template>
|
||||||
@@ -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,41 +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 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({
|
||||||
@@ -105,6 +147,8 @@ const liveClasses = createListResource({
|
|||||||
'description',
|
'description',
|
||||||
'time',
|
'time',
|
||||||
'date',
|
'date',
|
||||||
|
'duration',
|
||||||
|
'attendees',
|
||||||
'start_url',
|
'start_url',
|
||||||
'join_url',
|
'join_url',
|
||||||
'owner',
|
'owner',
|
||||||
@@ -116,6 +160,41 @@ const liveClasses = createListResource({
|
|||||||
const openLiveClassModal = () => {
|
const openLiveClassModal = () => {
|
||||||
showLiveClassModal.value = true
|
showLiveClassModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canCreateClass = () => {
|
||||||
|
if (readOnlyMode) return false
|
||||||
|
if (!props.zoomAccount) return false
|
||||||
|
return hasPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPermission = () => {
|
||||||
|
return user.data?.is_moderator || user.data?.is_evaluator
|
||||||
|
}
|
||||||
|
|
||||||
|
const canAccessClass = (cls) => {
|
||||||
|
if (cls.date < dayjs().format('YYYY-MM-DD')) return false
|
||||||
|
if (cls.date > dayjs().format('YYYY-MM-DD')) return false
|
||||||
|
if (hasClassEnded(cls)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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,12 +25,14 @@
|
|||||||
<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>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||||
{{ __('Announcement') }}
|
{{ __('Announcement') }}
|
||||||
|
<span class="text-ink-red-3">*</span>
|
||||||
</div>
|
</div>
|
||||||
<TextEditor
|
<TextEditor
|
||||||
:fixedMenu="true"
|
:fixedMenu="true"
|
||||||
@@ -43,9 +45,8 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, Input, TextEditor, createResource } from 'frappe-ui'
|
import { Dialog, Input, TextEditor, createResource, toast } from 'frappe-ui'
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { showToast } from '@/utils/'
|
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
|
|
||||||
@@ -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',
|
||||||
@@ -87,22 +88,24 @@ const makeAnnouncement = (close) => {
|
|||||||
{
|
{
|
||||||
validate() {
|
validate() {
|
||||||
if (!props.students.length) {
|
if (!props.students.length) {
|
||||||
return 'No students in this batch'
|
return __('No students in this batch')
|
||||||
}
|
}
|
||||||
if (!announcement.subject) {
|
if (!announcement.subject) {
|
||||||
return 'Subject is required'
|
return __('Subject is required')
|
||||||
|
}
|
||||||
|
if (!announcement.announcement) {
|
||||||
|
return __('Announcement is required')
|
||||||
|
}
|
||||||
|
if (!announcement.replyTo) {
|
||||||
|
return __('Reply To is required')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
close()
|
close()
|
||||||
showToast(
|
toast.success(__('Announcement has been sent successfully'))
|
||||||
__('Success'),
|
|
||||||
__('Announcement has been sent successfully'),
|
|
||||||
'check'
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast(__('Error'), __(err.messages?.[0] || err), 'alert-circle')
|
toast.error(__(err.messages?.[0] || err))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,21 +25,39 @@
|
|||||||
v-model="assessment"
|
v-model="assessment"
|
||||||
:doctype="assessmentType"
|
:doctype="assessmentType"
|
||||||
:label="__('Assessment')"
|
:label="__('Assessment')"
|
||||||
|
:onCreate="
|
||||||
|
(value, close) => {
|
||||||
|
close()
|
||||||
|
if (assessmentType === 'LMS Quiz') {
|
||||||
|
router.push({
|
||||||
|
name: 'QuizForm',
|
||||||
|
params: {
|
||||||
|
quizID: 'new',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else if (assessmentType === 'LMS Assignment') {
|
||||||
|
router.push({
|
||||||
|
name: 'Assignments',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, FormControl, createResource } from 'frappe-ui'
|
import { Dialog, FormControl, createResource, toast } from 'frappe-ui'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { showToast } from '@/utils'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const assessmentType = ref(null)
|
const assessmentType = ref(null)
|
||||||
const assessment = ref(null)
|
const assessment = ref(null)
|
||||||
const assessments = defineModel('assessments')
|
const assessments = defineModel('assessments')
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
@@ -70,7 +88,7 @@ const addAssessment = (close) => {
|
|||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
assessments.value.reload()
|
assessments.value.reload()
|
||||||
showToast(__('Success'), __('Assessment added successfully'), 'check')
|
toast.success(__('Assessment added successfully'))
|
||||||
close()
|
close()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -81,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>
|
||||||
|
|||||||
154
frontend/src/components/Modals/AssignmentForm.vue
Normal file
154
frontend/src/components/Modals/AssignmentForm.vue
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
size: 'lg',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="p-5 text-base">
|
||||||
|
<div class="text-lg text-ink-gray-9 font-semibold mb-5">
|
||||||
|
{{
|
||||||
|
assignmentID === 'new'
|
||||||
|
? __('Create an Assignment')
|
||||||
|
: __('Edit Assignment')
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4 max-h-[75vh] overflow-y-auto">
|
||||||
|
<FormControl
|
||||||
|
v-model="assignment.title"
|
||||||
|
:label="__('Title')"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="assignment.type"
|
||||||
|
type="select"
|
||||||
|
:options="assignmentOptions"
|
||||||
|
:label="__('Submission Type')"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-ink-gray-5 mb-2">
|
||||||
|
{{ __('Question') }}
|
||||||
|
<span class="text-ink-red-3">*</span>
|
||||||
|
</div>
|
||||||
|
<TextEditor
|
||||||
|
:content="assignment.question"
|
||||||
|
@change="(val) => (assignment.question = val)"
|
||||||
|
:editable="true"
|
||||||
|
:fixedMenu="true"
|
||||||
|
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[18rem] overflow-y-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-2 mt-5">
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'AssignmentSubmissionList',
|
||||||
|
query: {
|
||||||
|
assignmentID: assignmentID,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button v-if="assignmentID !== 'new'" variant="subtle">
|
||||||
|
{{ __('Check Submissions') }}
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<Button variant="solid" @click="saveAssignment">
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||||
|
import { computed, reactive, watch } from 'vue'
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const assignments = defineModel<Assignments>('assignments')
|
||||||
|
|
||||||
|
interface Assignment {
|
||||||
|
title: string
|
||||||
|
type: string
|
||||||
|
question: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Assignments {
|
||||||
|
data: Assignment[]
|
||||||
|
get: (params: { doctype: string; name: string }) => Promise<Assignment>
|
||||||
|
insert: {
|
||||||
|
submit: (params: Assignment, options: { onSuccess: () => void }) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignment = reactive({
|
||||||
|
title: '',
|
||||||
|
type: '',
|
||||||
|
question: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
assignmentID: {
|
||||||
|
type: String,
|
||||||
|
default: 'new',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.assignmentID,
|
||||||
|
(val) => {
|
||||||
|
if (val !== 'new') {
|
||||||
|
assignments.value?.data.forEach((row) => {
|
||||||
|
if (row.name === val) {
|
||||||
|
assignment.title = row.title
|
||||||
|
assignment.type = row.type
|
||||||
|
assignment.question = row.question
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ flush: 'post' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const saveAssignment = () => {
|
||||||
|
if (props.assignmentID == 'new') {
|
||||||
|
assignments.value.insert.submit(
|
||||||
|
{
|
||||||
|
...assignment,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
show.value = false
|
||||||
|
toast.success(__('Assignment created successfully'))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
assignments.value.setValue.submit(
|
||||||
|
{
|
||||||
|
...assignment,
|
||||||
|
name: props.assignmentID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
show.value = false
|
||||||
|
toast.success(__('Assignment updated successfully'))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignmentOptions = computed(() => {
|
||||||
|
return [
|
||||||
|
{ label: 'PDF', value: 'PDF' },
|
||||||
|
{ label: 'Image', value: 'Image' },
|
||||||
|
{ label: 'Document', value: 'Document' },
|
||||||
|
{ label: 'Text', value: 'Text' },
|
||||||
|
{ label: 'URL', value: 'URL' },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -19,32 +19,43 @@
|
|||||||
v-model="course"
|
v-model="course"
|
||||||
:label="__('Course')"
|
:label="__('Course')"
|
||||||
:required="true"
|
:required="true"
|
||||||
|
:onCreate="
|
||||||
|
(value, close) => {
|
||||||
|
close()
|
||||||
|
router.push({
|
||||||
|
name: 'CourseForm',
|
||||||
|
params: {
|
||||||
|
courseName: 'new',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
doctype="Course Evaluator"
|
doctype="Course Evaluator"
|
||||||
v-model="evaluator"
|
v-model="evaluator"
|
||||||
:label="__('Evaluator')"
|
:label="__('Evaluator')"
|
||||||
:onCreate="(value, close) => openSettings(close)"
|
:onCreate="(value, close) => openSettings('Evaluators', close)"
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, createResource } from 'frappe-ui'
|
import { Dialog, createResource, toast } from 'frappe-ui'
|
||||||
import { ref, inject } from 'vue'
|
import { ref, inject } from 'vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { showToast } from '@/utils'
|
|
||||||
import { useOnboarding } from 'frappe-ui/frappe'
|
import { useOnboarding } from 'frappe-ui/frappe'
|
||||||
import { useSettings } from '@/stores/settings'
|
import { openSettings } from '@/utils'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const course = ref(null)
|
const course = ref(null)
|
||||||
const evaluator = ref(null)
|
const evaluator = ref(null)
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const courses = defineModel('courses')
|
const courses = defineModel('courses')
|
||||||
|
const router = useRouter()
|
||||||
const { updateOnboardingStep } = useOnboarding('learning')
|
const { updateOnboardingStep } = useOnboarding('learning')
|
||||||
const settingsStore = useSettings()
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
@@ -83,15 +94,9 @@ const addCourse = (close) => {
|
|||||||
evaluator.value = null
|
evaluator.value = null
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast('Error', err.message[0] || err, 'x')
|
toast.error(err.messages?.[0] || err)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openSettings = (close) => {
|
|
||||||
close()
|
|
||||||
settingsStore.activeTab = 'Evaluators'
|
|
||||||
settingsStore.isSettingsOpen = true
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -14,7 +14,13 @@
|
|||||||
<div class="text-xl font-semibold">
|
<div class="text-xl font-semibold">
|
||||||
{{ student.full_name }}
|
{{ student.full_name }}
|
||||||
</div>
|
</div>
|
||||||
<Badge :theme="student.progress === 100 ? 'green' : 'red'">
|
<Badge
|
||||||
|
v-if="
|
||||||
|
Object.keys(student.assessments).length ||
|
||||||
|
Object.keys(student.courses).length
|
||||||
|
"
|
||||||
|
:theme="student.progress === 100 ? 'green' : 'red'"
|
||||||
|
>
|
||||||
{{ student.progress }}% {{ __('Complete') }}
|
{{ student.progress }}% {{ __('Complete') }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,7 +32,10 @@
|
|||||||
|
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<!-- Assessments -->
|
<!-- Assessments -->
|
||||||
<div class="space-y-2 text-sm">
|
<div
|
||||||
|
v-if="Object.keys(student.assessments).length"
|
||||||
|
class="space-y-2 text-sm"
|
||||||
|
>
|
||||||
<div class="flex items-center border-b pb-1 font-medium">
|
<div class="flex items-center border-b pb-1 font-medium">
|
||||||
<span class="flex-1">
|
<span class="flex-1">
|
||||||
{{ __('Assessment') }}
|
{{ __('Assessment') }}
|
||||||
@@ -73,7 +82,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Courses -->
|
<!-- Courses -->
|
||||||
<div class="space-y-2 text-sm">
|
<div
|
||||||
|
v-if="Object.keys(student.courses).length"
|
||||||
|
class="space-y-2 text-sm"
|
||||||
|
>
|
||||||
<div class="flex items-center border-b pb-1 font-medium">
|
<div class="flex items-center border-b pb-1 font-medium">
|
||||||
<span class="flex-1">
|
<span class="flex-1">
|
||||||
{{ __('Courses') }}
|
{{ __('Courses') }}
|
||||||
|
|||||||
@@ -62,9 +62,8 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { inject, reactive } from 'vue'
|
import { inject, reactive } from 'vue'
|
||||||
import { createResource, Dialog, FormControl, Switch } from 'frappe-ui'
|
import { createResource, Dialog, FormControl, Switch, toast } from 'frappe-ui'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { showToast } from '@/utils'
|
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const dayjs = inject('$dayjs')
|
const dayjs = inject('$dayjs')
|
||||||
@@ -112,13 +111,13 @@ const generateCertificates = (close) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast(__('Error'), err.messages?.[0] || err, 'x')
|
toast.error(err.messages?.[0] || err)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
close()
|
close()
|
||||||
showToast(__('Success'), __('Certificates generated successfully'), 'check')
|
toast.success(__('Certificates generated successfully'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCourses = () => {
|
const getCourses = () => {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<Button @click="openFileSelector" :loading="uploading">
|
<Button @click="openFileSelector" :loading="uploading">
|
||||||
{{
|
{{
|
||||||
uploading ? `Uploading ${progress}%` : 'Upload an zip file'
|
uploading ? `Uploading ${progress}%` : 'Upload an ZIP file'
|
||||||
}}
|
}}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,9 +76,10 @@ import {
|
|||||||
FileUploader,
|
FileUploader,
|
||||||
FormControl,
|
FormControl,
|
||||||
Switch,
|
Switch,
|
||||||
|
toast,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { reactive, watch, inject } from 'vue'
|
import { reactive, watch, inject } from 'vue'
|
||||||
import { showToast, getFileSize } from '@/utils/'
|
import { getFileSize } from '@/utils/'
|
||||||
import { capture } from '@/telemetry'
|
import { capture } from '@/telemetry'
|
||||||
import { FileText, X } from 'lucide-vue-next'
|
import { FileText, X } from 'lucide-vue-next'
|
||||||
import { useOnboarding } from 'frappe-ui/frappe'
|
import { useOnboarding } from 'frappe-ui/frappe'
|
||||||
@@ -150,21 +151,17 @@ const addChapter = async (close) => {
|
|||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
cleanChapter()
|
cleanChapter()
|
||||||
outline.value.reload()
|
outline.value.reload()
|
||||||
showToast(
|
toast.success(__('Chapter added successfully'))
|
||||||
__('Success'),
|
|
||||||
__('Chapter added successfully'),
|
|
||||||
'check'
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast(__('Error'), err.messages?.[0] || err, 'x')
|
toast.error(err.messages?.[0] || err)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
close()
|
close()
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast(__('Error'), err.messages?.[0] || err, 'x')
|
toast.error(err.messages?.[0] || err)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -196,11 +193,11 @@ const editChapter = (close) => {
|
|||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
outline.value.reload()
|
outline.value.reload()
|
||||||
showToast(__('Success'), __('Chapter updated successfully'), 'check')
|
toast.success(__('Chapter updated successfully'))
|
||||||
close()
|
close()
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast(__('Error'), err.messages?.[0] || err, 'x')
|
toast.error(err.messages?.[0] || err)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
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>
|
||||||
@@ -34,9 +34,15 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui'
|
import {
|
||||||
|
Dialog,
|
||||||
|
FormControl,
|
||||||
|
TextEditor,
|
||||||
|
createResource,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { showToast, singularize } from '@/utils'
|
import { singularize } from '@/utils'
|
||||||
|
|
||||||
const topics = defineModel('reloadTopics')
|
const topics = defineModel('reloadTopics')
|
||||||
|
|
||||||
@@ -115,7 +121,7 @@ const submitTopic = (close) => {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast('Error', err.message, 'x')
|
toast.error(err.messages?.[0] || err)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -93,10 +93,11 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
createResource,
|
createResource,
|
||||||
TextEditor,
|
TextEditor,
|
||||||
|
toast,
|
||||||
} 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, showToast, escapeHTML } from '@/utils'
|
import { getFileSize } from '@/utils'
|
||||||
|
|
||||||
const reloadProfile = defineModel('reloadProfile')
|
const reloadProfile = defineModel('reloadProfile')
|
||||||
|
|
||||||
@@ -131,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,
|
||||||
@@ -155,7 +155,7 @@ const saveProfile = (close) => {
|
|||||||
reloadProfile.value.reload()
|
reloadProfile.value.reload()
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast('Error', err.messages?.[0] || err, 'x')
|
toast.error(err.messages?.[0] || err)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
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>
|
||||||
@@ -42,10 +42,11 @@
|
|||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<div v-for="slot in slots.data">
|
<div v-for="slot in slots.data">
|
||||||
<div
|
<div
|
||||||
class="text-base text-center border rounded-md bg-surface-gray-3 p-2 cursor-pointer"
|
class="text-base text-center border rounded-md text-ink-gray-8 bg-surface-gray-3 p-2 cursor-pointer"
|
||||||
@click="saveSlot(slot)"
|
@click="saveSlot(slot)"
|
||||||
:class="{
|
:class="{
|
||||||
'border-gray-900': evaluation.start_time == slot.start_time,
|
'border-outline-gray-4':
|
||||||
|
evaluation.start_time == slot.start_time,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
{{ formatTime(slot.start_time) }} -
|
{{ formatTime(slot.start_time) }} -
|
||||||
@@ -65,9 +66,9 @@
|
|||||||
</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 { createToast, formatTime } from '@/utils/'
|
import { formatTime } from '@/utils/'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const dayjs = inject('$dayjs')
|
const dayjs = inject('$dayjs')
|
||||||
@@ -89,7 +90,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
let evaluation = reactive({
|
const evaluation = reactive({
|
||||||
course: '',
|
course: '',
|
||||||
date: '',
|
date: '',
|
||||||
start_time: '',
|
start_time: '',
|
||||||
@@ -138,29 +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
|
|
||||||
}
|
|
||||||
|
|
||||||
createToast({
|
|
||||||
title: unavailabilityMessage ? __('Evaluator is Unavailable') : '',
|
|
||||||
text: message,
|
|
||||||
icon: unavailabilityMessage ? 'alert-circle' : 'x',
|
|
||||||
iconClasses: 'bg-yellow-600 text-ink-white rounded-md p-px',
|
|
||||||
position: 'top-center',
|
|
||||||
timeout: 10,
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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({
|
||||||
@@ -170,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"
|
||||||
@@ -144,6 +144,7 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Textarea,
|
Textarea,
|
||||||
|
toast,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
@@ -157,7 +158,7 @@ import {
|
|||||||
ClipboardList,
|
ClipboardList,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { inject, reactive, watch, ref, computed } from 'vue'
|
import { inject, reactive, watch, ref, computed } from 'vue'
|
||||||
import { formatTime, showToast } from '@/utils'
|
import { formatTime } from '@/utils'
|
||||||
import Rating from '@/components/Controls/Rating.vue'
|
import Rating from '@/components/Controls/Rating.vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
@@ -252,7 +253,10 @@ const saveEvaluation = () => {
|
|||||||
} else {
|
} else {
|
||||||
show.value = false
|
show.value = false
|
||||||
}
|
}
|
||||||
showToast(__('Success'), __('Evaluation saved successfully'), 'check')
|
toast.success(__('Evaluation saved successfully'))
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -276,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({
|
||||||
@@ -307,7 +314,10 @@ const saveCertificate = () => {
|
|||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
showToast(__('Success'), __('Certificate saved successfully'), 'check')
|
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>
|
||||||
@@ -64,10 +64,10 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, FileUploader, Button, createResource } from 'frappe-ui'
|
import { Dialog, FileUploader, Button, createResource, toast } from 'frappe-ui'
|
||||||
import { FileText } from 'lucide-vue-next'
|
import { FileText } from 'lucide-vue-next'
|
||||||
import { ref, inject } from 'vue'
|
import { ref, inject } from 'vue'
|
||||||
import { createToast, getFileSize } from '@/utils/'
|
import { getFileSize } from '@/utils/'
|
||||||
|
|
||||||
const resume = ref(null)
|
const resume = ref(null)
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
@@ -112,24 +112,12 @@ const submitResume = (close) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
createToast({
|
toast.success('Your application has been submitted successfully')
|
||||||
title: 'Success',
|
|
||||||
text: 'Your application has been submitted',
|
|
||||||
icon: 'check',
|
|
||||||
iconClasses: 'bg-surface-green-3 text-ink-white rounded-md p-px',
|
|
||||||
})
|
|
||||||
application.value.reload()
|
application.value.reload()
|
||||||
close()
|
close()
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
createToast({
|
toast.error(err.messages?.[0] || err)
|
||||||
title: 'Error',
|
|
||||||
text: err.messages?.[0] || err,
|
|
||||||
icon: 'x',
|
|
||||||
iconClasses: 'bg-surface-red-5 text-ink-white rounded-md p-px',
|
|
||||||
position: 'top-center',
|
|
||||||
timeout: 10,
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
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"
|
||||||
@@ -94,9 +90,10 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
FormControl,
|
FormControl,
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
|
toast,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { reactive, inject, onMounted } from 'vue'
|
import { reactive, inject, onMounted } from 'vue'
|
||||||
import { getTimezones, createToast, getUserTimezone } from '@/utils/'
|
import { getTimezones, getUserTimezone } from '@/utils/'
|
||||||
|
|
||||||
const liveClasses = defineModel('reloadLiveClasses')
|
const liveClasses = defineModel('reloadLiveClasses')
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
@@ -106,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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -158,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,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -166,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.')
|
||||||
}
|
}
|
||||||
@@ -196,22 +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) {
|
|
||||||
createToast({
|
|
||||||
title: 'Error',
|
|
||||||
text: err.messages?.[0] || err,
|
|
||||||
icon: 'x',
|
|
||||||
iconClasses: 'bg-surface-red-5 text-ink-white rounded-md p-px',
|
|
||||||
position: 'top-center',
|
|
||||||
timeout: 10,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const valideTime = () => {
|
const valideTime = () => {
|
||||||
@@ -227,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>
|
||||||
|
|||||||
@@ -30,11 +30,10 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, createResource } from 'frappe-ui'
|
import { Dialog, createResource, toast } from 'frappe-ui'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { reactive, watch } from 'vue'
|
import { reactive, watch } from 'vue'
|
||||||
import IconPicker from '@/components/Controls/IconPicker.vue'
|
import IconPicker from '@/components/Controls/IconPicker.vue'
|
||||||
import { showToast } from '@/utils'
|
|
||||||
|
|
||||||
const sidebar = defineModel('reloadSidebar')
|
const sidebar = defineModel('reloadSidebar')
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
@@ -78,10 +77,10 @@ const addWebPage = (close) => {
|
|||||||
onSuccess() {
|
onSuccess() {
|
||||||
sidebar.value.reload()
|
sidebar.value.reload()
|
||||||
close()
|
close()
|
||||||
showToast('Success', 'Web page added to sidebar', 'check')
|
toast.success(__('Web page added to sidebar'))
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast('Error', err.message[0] || err, 'x')
|
toast.error(err.message[0] || err)
|
||||||
close()
|
close()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dialog v-model="show" :options="dialogOptions">
|
<Dialog
|
||||||
<template #body-content>
|
v-model="show"
|
||||||
<div class="space-y-4">
|
:options="{
|
||||||
|
size: '5xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="p-5 space-y-5">
|
||||||
|
<div class="text-lg font-semibold text-ink-gray-9 mb-5">
|
||||||
|
{{ __(props.title) }}
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="!editMode"
|
v-if="!editMode"
|
||||||
class="flex items-center text-xs text-ink-gray-7 space-x-5"
|
class="flex items-center text-xs text-ink-gray-7 space-x-5"
|
||||||
>
|
>
|
||||||
<div class="flex items-center space-x-2">
|
<Switch
|
||||||
<input
|
size="sm"
|
||||||
type="radio"
|
:label="__('Choose an existing question')"
|
||||||
id="existing"
|
v-model="chooseFromExisting"
|
||||||
value="existing"
|
class="!p-0"
|
||||||
v-model="questionType"
|
|
||||||
class="w-3 h-3 cursor-pointer"
|
|
||||||
/>
|
/>
|
||||||
<label for="existing" class="cursor-pointer">
|
|
||||||
{{ __('Add an existing question') }}
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="!chooseFromExisting || editMode">
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
id="new"
|
|
||||||
value="new"
|
|
||||||
v-model="questionType"
|
|
||||||
class="w-3 h-3 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<label for="new" class="cursor-pointer">
|
|
||||||
{{ __('Create a new question') }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="questionType == 'new' || editMode" class="space-y-2">
|
|
||||||
<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') }}
|
||||||
@@ -45,6 +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-8 mt-4">
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="question.marks"
|
v-model="question.marks"
|
||||||
:label="__('Marks')"
|
:label="__('Marks')"
|
||||||
@@ -58,7 +48,23 @@
|
|||||||
class="pb-2"
|
class="pb-2"
|
||||||
:required="true"
|
:required="true"
|
||||||
/>
|
/>
|
||||||
<div v-if="question.type == 'Choices'" class="divide-y border-t">
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="question.type == 'Choices'"
|
||||||
|
class="text-base font-semibold text-ink-gray-9 mb-5 mt-10"
|
||||||
|
>
|
||||||
|
{{ __('Options') }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="question.type == 'User Input'"
|
||||||
|
class="text-base font-semibold text-ink-gray-9 mb-5 mt-5"
|
||||||
|
>
|
||||||
|
{{ __('Possibilities') }}
|
||||||
|
</div>
|
||||||
|
<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"
|
||||||
@@ -78,9 +84,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="question.type == 'User Input'"
|
v-else-if="question.type == 'User Input'"
|
||||||
v-for="n in 4"
|
class="grid grid-cols-2 gap-x-8 gap-y-4 py-2"
|
||||||
class="space-y-2"
|
|
||||||
>
|
>
|
||||||
|
<div v-for="n in 4">
|
||||||
<FormControl
|
<FormControl
|
||||||
:label="__('Possibility') + ' ' + n"
|
:label="__('Possibility') + ' ' + n"
|
||||||
v-model="question[`possibility_${n}`]"
|
v-model="question[`possibility_${n}`]"
|
||||||
@@ -88,7 +94,8 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="questionType == 'existing'" class="space-y-2">
|
</div>
|
||||||
|
<div v-else-if="chooseFromExisting" class="space-y-2">
|
||||||
<Link
|
<Link
|
||||||
v-model="existingQuestion.question"
|
v-model="existingQuestion.question"
|
||||||
:label="__('Select a question')"
|
:label="__('Select a question')"
|
||||||
@@ -100,20 +107,32 @@
|
|||||||
type="number"
|
type="number"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center justify-end space-x-2 mt-5">
|
||||||
|
<Button variant="solid" @click="submitQuestion()">
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui'
|
import {
|
||||||
|
Dialog,
|
||||||
|
FormControl,
|
||||||
|
TextEditor,
|
||||||
|
createResource,
|
||||||
|
Switch,
|
||||||
|
Button,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
import { computed, watch, reactive, ref, inject } from 'vue'
|
import { computed, watch, reactive, ref, inject } from 'vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { showToast } from '@/utils'
|
|
||||||
import { useOnboarding } from 'frappe-ui/frappe'
|
import { useOnboarding } from 'frappe-ui/frappe'
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const quiz = defineModel('quiz')
|
const quiz = defineModel('quiz')
|
||||||
const questionType = ref(null)
|
const chooseFromExisting = ref(false)
|
||||||
const editMode = ref(false)
|
const editMode = ref(false)
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const { updateOnboardingStep } = useOnboarding('learning')
|
const { updateOnboardingStep } = useOnboarding('learning')
|
||||||
@@ -182,11 +201,12 @@ watch(show, () => {
|
|||||||
editMode.value = false
|
editMode.value = false
|
||||||
if (props.questionDetail.question) questionData.fetch()
|
if (props.questionDetail.question) questionData.fetch()
|
||||||
else {
|
else {
|
||||||
;(question.question = ''), (question.marks = 0)
|
question.question = ''
|
||||||
|
question.marks = 1
|
||||||
question.type = 'Choices'
|
question.type = 'Choices'
|
||||||
existingQuestion.question = ''
|
existingQuestion.question = ''
|
||||||
existingQuestion.marks = 0
|
existingQuestion.marks = 1
|
||||||
questionType.value = null
|
chooseFromExisting.value = false
|
||||||
populateFields()
|
populateFields()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,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,
|
||||||
@@ -221,42 +241,36 @@ const questionCreation = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const submitQuestion = (close) => {
|
const submitQuestion = () => {
|
||||||
if (props.questionDetail?.question) updateQuestion(close)
|
if (props.questionDetail?.question) updateQuestion()
|
||||||
else addQuestion(close)
|
else addQuestion()
|
||||||
}
|
}
|
||||||
|
|
||||||
const addQuestion = (close) => {
|
const addQuestion = () => {
|
||||||
if (questionType.value == 'existing') {
|
if (chooseFromExisting.value) {
|
||||||
addQuestionRow(
|
addQuestionRow({
|
||||||
{
|
|
||||||
question: existingQuestion.question,
|
question: existingQuestion.question,
|
||||||
marks: existingQuestion.marks,
|
marks: existingQuestion.marks,
|
||||||
},
|
})
|
||||||
close
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
questionCreation.submit(
|
questionCreation.submit(
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
addQuestionRow(
|
addQuestionRow({
|
||||||
{
|
|
||||||
question: data.name,
|
question: data.name,
|
||||||
marks: question.marks,
|
marks: question.marks,
|
||||||
},
|
})
|
||||||
close
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
toast.error(err.messages?.[0] || err)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const addQuestionRow = (question, close) => {
|
const addQuestionRow = (question) => {
|
||||||
questionRow.submit(
|
questionRow.submit(
|
||||||
{
|
{
|
||||||
...question,
|
...question,
|
||||||
@@ -267,13 +281,13 @@ const addQuestionRow = (question, close) => {
|
|||||||
updateOnboardingStep('create_first_quiz')
|
updateOnboardingStep('create_first_quiz')
|
||||||
|
|
||||||
show.value = false
|
show.value = false
|
||||||
showToast(__('Success'), __('Question added successfully'), 'check')
|
toast.success(__('Question added successfully'))
|
||||||
quiz.value.reload()
|
quiz.value.reload()
|
||||||
close()
|
show.value = false
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
toast.error(err.messages?.[0] || err)
|
||||||
close()
|
show.value = false
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -307,7 +321,7 @@ const marksUpdate = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateQuestion = (close) => {
|
const updateQuestion = () => {
|
||||||
questionUpdate.submit(
|
questionUpdate.submit(
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
@@ -317,39 +331,18 @@ const updateQuestion = (close) => {
|
|||||||
{
|
{
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
show.value = false
|
show.value = false
|
||||||
showToast(
|
toast.success(__('Question updated successfully'))
|
||||||
__('Success'),
|
|
||||||
__('Question updated successfully'),
|
|
||||||
'check'
|
|
||||||
)
|
|
||||||
quiz.value.reload()
|
quiz.value.reload()
|
||||||
close()
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
toast.error(err.messages?.[0] || err)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const dialogOptions = computed(() => {
|
|
||||||
return {
|
|
||||||
title: __(props.title),
|
|
||||||
size: 'xl',
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
label: __('Submit'),
|
|
||||||
variant: 'solid',
|
|
||||||
onClick: (close) => {
|
|
||||||
submitQuestion(close)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
input[type='radio']:checked {
|
input[type='radio']:checked {
|
||||||
|
|||||||
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,27 +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 } 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'
|
|
||||||
import { createToast } from '@/utils/'
|
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const reviews = defineModel('reloadReviews')
|
const reviews = defineModel('reloadReviews')
|
||||||
@@ -78,11 +71,7 @@ function submitReview(close) {
|
|||||||
hasReviewed.value.reload()
|
hasReviewed.value.reload()
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
createToast({
|
toast.error(err.messages?.[0] || err)
|
||||||
text: err.messages?.[0] || err,
|
|
||||||
icon: 'x',
|
|
||||||
iconClasses: 'text-ink-red-4 bg-surface-red-4',
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
close()
|
close()
|
||||||
|
|||||||
@@ -19,19 +19,25 @@
|
|||||||
doctype="User"
|
doctype="User"
|
||||||
v-model="student"
|
v-model="student"
|
||||||
:filters="{ ignore_user_type: 1 }"
|
:filters="{ ignore_user_type: 1 }"
|
||||||
|
:onCreate="
|
||||||
|
(value, close) => {
|
||||||
|
openSettings('Members', close)
|
||||||
|
}
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, createResource } from 'frappe-ui'
|
import { Dialog, createResource, toast } from 'frappe-ui'
|
||||||
import { ref, inject } from 'vue'
|
import { ref, inject } from 'vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { showToast } from '@/utils'
|
|
||||||
import { useOnboarding } from 'frappe-ui/frappe'
|
import { useOnboarding } from 'frappe-ui/frappe'
|
||||||
|
import { openSettings } from '@/utils'
|
||||||
|
|
||||||
const students = defineModel('reloadStudents')
|
const students = defineModel('reloadStudents')
|
||||||
|
const batchModal = defineModel('batchModal')
|
||||||
const student = ref()
|
const student = ref()
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const { updateOnboardingStep } = useOnboarding('learning')
|
const { updateOnboardingStep } = useOnboarding('learning')
|
||||||
@@ -66,11 +72,12 @@ const addStudent = (close) => {
|
|||||||
updateOnboardingStep('add_batch_student')
|
updateOnboardingStep('add_batch_student')
|
||||||
|
|
||||||
students.value.reload()
|
students.value.reload()
|
||||||
|
batchModal.value.reload()
|
||||||
student.value = null
|
student.value = null
|
||||||
close()
|
close()
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
toast.error(err.messages?.[0] || err)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
252
frontend/src/components/Modals/VideoStatistics.vue
Normal file
252
frontend/src/components/Modals/VideoStatistics.vue
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
<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 h-[70vh] overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-[70%,30%] text-sm text-ink-gray-5">
|
||||||
|
<div class="px-4">
|
||||||
|
{{ __('Member') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
{{ __('Watch Time') }}
|
||||||
|
</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">
|
||||||
|
{{ formatTimestamp(row.watch_time) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<NumberChart
|
||||||
|
class="border rounded-md"
|
||||||
|
:config="{
|
||||||
|
title: __('Average Watch Time'),
|
||||||
|
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, formatTimestamp } 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 formatTimestamp(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>
|
||||||
241
frontend/src/components/Notes/InlineLessonMenu.vue
Normal file
241
frontend/src/components/Notes/InlineLessonMenu.vue
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="text-sm absolute bg-white border rounded-md z-10 w-44"
|
||||||
|
:style="{
|
||||||
|
display: top > 0 ? 'block' : 'none',
|
||||||
|
top: top + 'px',
|
||||||
|
left: left + 'px',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="space-y-2 py-2">
|
||||||
|
<div class="text-xs text-ink-gray-5 font-medium px-3">
|
||||||
|
{{ __('Highlight') }}
|
||||||
|
</div>
|
||||||
|
<div class="">
|
||||||
|
<div
|
||||||
|
v-for="color in colors"
|
||||||
|
class="flex items-center space-x-2 px-3 py-2 cursor-pointer hover:bg-surface-gray-2"
|
||||||
|
@click="saveHighLight(color)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="size-3 rounded-full"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: theme.backgroundColor[color.toLowerCase()][400],
|
||||||
|
}"
|
||||||
|
></span>
|
||||||
|
<span>
|
||||||
|
{{ __(color) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="border-t">
|
||||||
|
<div
|
||||||
|
@click="addToNotes()"
|
||||||
|
class="flex items-center space-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
|
||||||
|
>
|
||||||
|
<NotepadText class="size-3 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ __('Add to Notes') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="highlightExists()"
|
||||||
|
@click="deleteHighlight"
|
||||||
|
class="flex items-center space-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
|
||||||
|
>
|
||||||
|
<Trash2 class="size-3 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ __('Remove Highlight') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, inject, ref, watch } from 'vue'
|
||||||
|
import { NotepadText, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { theme } from '@/utils/theme'
|
||||||
|
import type { Note, Notes } from '@/components/Notes/types'
|
||||||
|
import { blockQuotesClick, highlightText } from '@/utils'
|
||||||
|
|
||||||
|
const user = inject<any>('$user')
|
||||||
|
const show = defineModel()
|
||||||
|
const notes = defineModel<Notes>('notes')
|
||||||
|
const top = ref(0)
|
||||||
|
const left = ref(0)
|
||||||
|
const currentSelection = ref<Selection | null>(null)
|
||||||
|
const selectedText = ref('')
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'updateNotes'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
lesson: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
watch(show, () => {
|
||||||
|
if (!show.value) {
|
||||||
|
return resetMenuPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSelection.value = window.getSelection()
|
||||||
|
if (!currentSelection.value?.toString()) {
|
||||||
|
return resetMenuPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMenuPosition()
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMenuPosition = () => {
|
||||||
|
selectedText.value = currentSelection.value?.toString() || ''
|
||||||
|
const range = currentSelection.value?.getRangeAt(0)
|
||||||
|
const rect = range?.getBoundingClientRect()
|
||||||
|
if (!rect) return
|
||||||
|
|
||||||
|
const offsetY = window.scrollY
|
||||||
|
const offsetX = window.scrollX
|
||||||
|
|
||||||
|
top.value = Math.floor(rect.top + offsetY - 40)
|
||||||
|
left.value = Math.floor(rect.right + offsetX + 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetMenuPosition = () => {
|
||||||
|
top.value = 0
|
||||||
|
left.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = computed(() => {
|
||||||
|
return ['Red', 'Blue', 'Green', 'Yellow', 'Purple']
|
||||||
|
})
|
||||||
|
|
||||||
|
const highlightExists = () => {
|
||||||
|
return notes.value?.data?.some(
|
||||||
|
(note: Note) => note.highlighted_text === selectedText.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveHighLight = (color: string) => {
|
||||||
|
if (!selectedText.value) return
|
||||||
|
|
||||||
|
notes.value?.insert.submit(
|
||||||
|
{
|
||||||
|
lesson: props.lesson,
|
||||||
|
member: user?.data?.name,
|
||||||
|
highlighted_text: selectedText.value,
|
||||||
|
color: color,
|
||||||
|
name: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data: Note) {
|
||||||
|
highlightText(data)
|
||||||
|
resetStates()
|
||||||
|
emit('updateNotes')
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
console.error('Error saving highlight:', err)
|
||||||
|
resetStates()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteHighlight = () => {
|
||||||
|
let notesToDelete = notes.value?.data.find(
|
||||||
|
(note: Note) => note.highlighted_text === selectedText.value
|
||||||
|
)
|
||||||
|
if (!notesToDelete) return
|
||||||
|
notes.value?.delete.submit(notesToDelete.name, {
|
||||||
|
onSuccess() {
|
||||||
|
resetStates()
|
||||||
|
document.querySelectorAll('.highlighted-text').forEach((el) => {
|
||||||
|
const element = el as HTMLElement
|
||||||
|
if (element.dataset.name === notesToDelete.name) {
|
||||||
|
element.style.backgroundColor = 'transparent'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
console.error('Error deleting highlight:', err)
|
||||||
|
resetStates()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addToNotes = () => {
|
||||||
|
if (!selectedText.value) return
|
||||||
|
let noteToUpdate = notes.value?.data.find((note: Note) => {
|
||||||
|
return !note.highlighted_text && note.note !== ''
|
||||||
|
})
|
||||||
|
if (!noteToUpdate) {
|
||||||
|
createNote()
|
||||||
|
} else {
|
||||||
|
updateNote(noteToUpdate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createNote = () => {
|
||||||
|
notes.value?.insert.submit(
|
||||||
|
{
|
||||||
|
lesson: props.lesson,
|
||||||
|
member: user?.data?.name,
|
||||||
|
note: `<blockquote><p>${selectedText.value}</p></blockquote><br>`,
|
||||||
|
color: 'Yellow',
|
||||||
|
name: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data: Note) {
|
||||||
|
emit('updateNotes')
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollToText(selectedText.value)
|
||||||
|
blockQuotesClick()
|
||||||
|
resetStates()
|
||||||
|
}, 100)
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
console.error('Error creating note:', err)
|
||||||
|
resetStates()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateNote = (noteToUpdate: Note) => {
|
||||||
|
notes.value?.setValue.submit(
|
||||||
|
{
|
||||||
|
name: noteToUpdate.name,
|
||||||
|
note: `${noteToUpdate.note}\n\n<blockquote><p>${selectedText.value}</p></blockquote><br>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data: Note) {
|
||||||
|
emit('updateNotes')
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollToText(selectedText.value)
|
||||||
|
blockQuotesClick()
|
||||||
|
resetStates()
|
||||||
|
}, 100)
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
console.error('Error updating note:', err)
|
||||||
|
resetStates()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToText = (text: string) => {
|
||||||
|
const elements = document.querySelectorAll('blockquote p')
|
||||||
|
Array.from(elements).forEach((el) => {
|
||||||
|
const element = el as HTMLElement
|
||||||
|
if (element.textContent?.toLowerCase().includes(text.toLowerCase())) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetStates = () => {
|
||||||
|
selectedText.value = ''
|
||||||
|
show.value = false
|
||||||
|
resetMenuPosition()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
115
frontend/src/components/Notes/Notes.vue
Normal file
115
frontend/src/components/Notes/Notes.vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-lg font-semibold mb-4">
|
||||||
|
{{ __('My Notes') }}
|
||||||
|
</div>
|
||||||
|
<TextEditor
|
||||||
|
:content="note"
|
||||||
|
:placeholder="__('Make notes for quick revision. Press / for menu.')"
|
||||||
|
@change="(val: string) => updateNoteText(val)"
|
||||||
|
:editable="true"
|
||||||
|
editorClass="prose prose-sm min-h-[200px] max-w-none"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { TextEditor } from 'frappe-ui'
|
||||||
|
import { useDebounceFn } from '@vueuse/core'
|
||||||
|
import { inject, ref, onMounted, watch } from 'vue'
|
||||||
|
import type { Note, Notes } from '@/components/Notes/types'
|
||||||
|
import { blockQuotesClick } from '@/utils/'
|
||||||
|
|
||||||
|
const note = ref<string | null>(null)
|
||||||
|
const currentNoteName = ref<string | null>(null)
|
||||||
|
const user = inject<any>('$user')
|
||||||
|
const notes = defineModel<Notes>('notes')
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'updateNotes'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
lesson: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateCurrentNote()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => notes.value?.data,
|
||||||
|
() => {
|
||||||
|
updateCurrentNote()
|
||||||
|
blockQuotesClick()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateCurrentNote = () => {
|
||||||
|
const currentNote = notes.value?.data?.filter((row: Note) => {
|
||||||
|
return !row.highlighted_text && row.note !== ''
|
||||||
|
})
|
||||||
|
if (currentNote?.length === 0) {
|
||||||
|
note.value = null
|
||||||
|
currentNoteName.value = null
|
||||||
|
return
|
||||||
|
} else if (currentNote && currentNote.length > 0) {
|
||||||
|
currentNoteName.value = currentNote[0].name
|
||||||
|
note.value = currentNote[0].note || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateNoteText = (val: string) => {
|
||||||
|
note.value = val
|
||||||
|
debouncedSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedSave = useDebounceFn(() => {
|
||||||
|
saveNotes()
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
|
const saveNotes = () => {
|
||||||
|
if (currentNoteName.value) {
|
||||||
|
updateNote()
|
||||||
|
} else {
|
||||||
|
createNote()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createNote = () => {
|
||||||
|
notes.value?.insert.submit(
|
||||||
|
{
|
||||||
|
lesson: props.lesson,
|
||||||
|
member: user?.data?.name,
|
||||||
|
note: note.value,
|
||||||
|
color: 'Yellow',
|
||||||
|
name: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data: Note) {
|
||||||
|
currentNoteName.value = data.name || null
|
||||||
|
emit('updateNotes')
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
console.error('Error creating note:', err)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateNote = () => {
|
||||||
|
if (!currentNoteName.value) return
|
||||||
|
notes.value?.setValue.submit(
|
||||||
|
{
|
||||||
|
name: currentNoteName.value,
|
||||||
|
lesson: props.lesson,
|
||||||
|
member: user?.data?.name,
|
||||||
|
note: note.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data: Note) {
|
||||||
|
emit('updateNotes')
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
console.error('Error updating note:', err)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
32
frontend/src/components/Notes/types.ts
Normal file
32
frontend/src/components/Notes/types.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
|
||||||
|
export type Note = {
|
||||||
|
highlighted_text?: string
|
||||||
|
color?: string
|
||||||
|
name: string
|
||||||
|
note?: string | null
|
||||||
|
lesson?: string
|
||||||
|
member?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Notes = {
|
||||||
|
data: Note[]
|
||||||
|
reload: () => void
|
||||||
|
insert: {
|
||||||
|
submit: (
|
||||||
|
data: Note,
|
||||||
|
options: { onSuccess: (data: Note) => void; onError: (err: any) => void }
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
setValue: {
|
||||||
|
submit: (
|
||||||
|
data: Note,
|
||||||
|
options: { onSuccess: (data: Note) => void; onError: (err: any) => void }
|
||||||
|
) => void
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
submit: (
|
||||||
|
data: Note | string,
|
||||||
|
options?: { onSuccess: () => void; onError: (err: any) => void }
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="showOnboardingBanner && onboardingDetails.data">
|
|
||||||
<Tooltip :text="__('Skip Onboarding')" placement="left">
|
|
||||||
<X
|
|
||||||
class="w-4 h-4 stroke-1 absolute top-2 right-2 cursor-pointer mr-1"
|
|
||||||
@click="skipOnboarding.reload()"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<div class="flex items-center justify-evenly bg-surface-gray-2 p-10">
|
|
||||||
<div
|
|
||||||
@click="redirectToCourseForm()"
|
|
||||||
class="flex items-center space-x-2"
|
|
||||||
:class="{
|
|
||||||
'cursor-pointer': !onboardingDetails.data.course_created?.length,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-if="onboardingDetails.data.course_created?.length"
|
|
||||||
class="py-1 px-1 bg-surface-white rounded-full"
|
|
||||||
>
|
|
||||||
<Check class="h-4 w-4 stroke-2 text-ink-green-3" />
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="font-semibold bg-surface-white px-2 py-1 rounded-full"
|
|
||||||
>
|
|
||||||
1
|
|
||||||
</span>
|
|
||||||
<span class="text-lg font-semibold">
|
|
||||||
{{ __('Create a course') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
@click="redirectToChapterForm()"
|
|
||||||
class="flex items-center space-x-2"
|
|
||||||
:class="{
|
|
||||||
'cursor-pointer':
|
|
||||||
onboardingDetails.data.course_created?.length &&
|
|
||||||
!onboardingDetails.data.chapter_created?.length,
|
|
||||||
'text-ink-gray-3': !onboardingDetails.data.course_created?.length,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-if="onboardingDetails.data.chapter_created?.length"
|
|
||||||
class="py-1 px-1 bg-surface-white rounded-full"
|
|
||||||
>
|
|
||||||
<Check class="h-4 w-4 stroke-2 text-ink-green-3" />
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="font-semibold bg-surface-white px-2 py-1 rounded-full"
|
|
||||||
>
|
|
||||||
2
|
|
||||||
</span>
|
|
||||||
<span class="text-lg font-semibold">
|
|
||||||
{{ __('Add a chapter') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
@click="redirectToLessonForm()"
|
|
||||||
class="flex items-center space-x-2"
|
|
||||||
:class="{
|
|
||||||
'cursor-pointer':
|
|
||||||
onboardingDetails.data.course_created?.length &&
|
|
||||||
onboardingDetails.data.chapter_created?.length,
|
|
||||||
'text-ink-gray-3':
|
|
||||||
!onboardingDetails.data.course_created?.length ||
|
|
||||||
!onboardingDetails.data.chapter_created?.length,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-if="onboardingDetails.data.lesson_created?.length"
|
|
||||||
class="py-1 px-1 bg-surface-white rounded-full"
|
|
||||||
>
|
|
||||||
<Check class="h-4 w-4 stroke-2 text-ink-green-3" />
|
|
||||||
</span>
|
|
||||||
<span class="font-semibold bg-surface-white px-2 py-1 rounded-full">
|
|
||||||
3
|
|
||||||
</span>
|
|
||||||
<span class="text-lg font-semibold">
|
|
||||||
{{ __('Add a lesson') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { Check, X } from 'lucide-vue-next'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useSettings } from '@/stores/settings'
|
|
||||||
import { createResource, Tooltip } from 'frappe-ui'
|
|
||||||
|
|
||||||
const showOnboardingBanner = ref(false)
|
|
||||||
const settings = useSettings()
|
|
||||||
const onboardingDetails = settings.onboardingDetails
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
watch(onboardingDetails, () => {
|
|
||||||
if (!onboardingDetails.data?.is_onboarded) {
|
|
||||||
showOnboardingBanner.value = true
|
|
||||||
} else {
|
|
||||||
showOnboardingBanner.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const redirectToCourseForm = () => {
|
|
||||||
if (onboardingDetails.data?.course_created.length) {
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
router.push({ name: 'CourseForm', params: { courseName: 'new' } })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const redirectToChapterForm = () => {
|
|
||||||
if (!onboardingDetails.data?.course_created.length) {
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
router.push({
|
|
||||||
name: 'CourseForm',
|
|
||||||
params: {
|
|
||||||
courseName: onboardingDetails.data?.first_course,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const redirectToLessonForm = () => {
|
|
||||||
if (!onboardingDetails.data?.course_created.length) {
|
|
||||||
return
|
|
||||||
} else if (!onboardingDetails.data?.chapter_created.length) {
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
router.push({
|
|
||||||
name: 'LessonForm',
|
|
||||||
params: {
|
|
||||||
courseName: onboardingDetails.data?.first_course,
|
|
||||||
chapterNumber: 1,
|
|
||||||
lessonNumber: 1,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const skipOnboarding = createResource({
|
|
||||||
url: 'frappe.client.set_value',
|
|
||||||
makeParams() {
|
|
||||||
return {
|
|
||||||
doctype: 'LMS Settings',
|
|
||||||
name: 'LMS Settings',
|
|
||||||
fieldname: 'is_onboarding_complete',
|
|
||||||
value: 1,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess(data) {
|
|
||||||
onboardingDetails.reload()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</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="
|
||||||
@@ -291,9 +320,9 @@ import {
|
|||||||
ListView,
|
ListView,
|
||||||
TextEditor,
|
TextEditor,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
toast,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { ref, watch, reactive, inject, computed } from 'vue'
|
import { ref, watch, reactive, inject, computed } from 'vue'
|
||||||
import { createToast, showToast } from '@/utils/'
|
|
||||||
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
|
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
|
||||||
import { timeAgo } from '@/utils'
|
import { timeAgo } from '@/utils'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
@@ -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({
|
||||||
@@ -494,12 +530,7 @@ const getAnswers = () => {
|
|||||||
const checkAnswer = () => {
|
const checkAnswer = () => {
|
||||||
let answers = getAnswers()
|
let answers = getAnswers()
|
||||||
if (!answers.length) {
|
if (!answers.length) {
|
||||||
createToast({
|
toast.warning(__('Please select an option'))
|
||||||
title: 'Please select an option',
|
|
||||||
icon: 'alert-circle',
|
|
||||||
iconClasses: 'text-yellow-600 bg-yellow-100 rounded-full',
|
|
||||||
position: 'top-center',
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,7 +620,7 @@ const createSubmission = () => {
|
|||||||
const errorTitle = err?.message || ''
|
const errorTitle = err?.message || ''
|
||||||
if (errorTitle.includes('MaximumAttemptsExceededError')) {
|
if (errorTitle.includes('MaximumAttemptsExceededError')) {
|
||||||
const errorMessage = err.messages?.[0] || err
|
const errorMessage = err.messages?.[0] || err
|
||||||
showToast(__('Error'), __(errorMessage), 'x')
|
toast.error(__(errorMessage))
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
}, 3000)
|
}, 3000)
|
||||||
@@ -616,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],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -653,3 +690,8 @@ const getSubmissionColumns = () => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<style>
|
||||||
|
p {
|
||||||
|
line-height: 1.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
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,52 +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 #icon>
|
<template #prefix>
|
||||||
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
|
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
|
||||||
<X v-else class="h-3 w-3 stroke-1.5" />
|
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</FormControl>
|
||||||
</div>
|
<div class="overflow-y-scroll h-[60vh]">
|
||||||
</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)"
|
||||||
@@ -59,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 }}
|
||||||
@@ -87,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"
|
||||||
@@ -110,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')
|
||||||
@@ -173,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
|
||||||
@@ -181,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',
|
||||||
@@ -193,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',
|
||||||
@@ -203,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,
|
||||||
},
|
},
|
||||||
@@ -217,8 +220,9 @@ const newMember = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const addMember = () => {
|
const addMember = (close: () => void) => {
|
||||||
newMember.reload()
|
newMember.reload()
|
||||||
|
close()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(search, () => {
|
watch(search, () => {
|
||||||
@@ -227,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) {
|
||||||
@@ -27,9 +27,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Button, Badge } from 'frappe-ui'
|
import { Button, Badge, toast } from 'frappe-ui'
|
||||||
import SettingFields from '@/components/SettingFields.vue'
|
import SettingFields from '@/components/Settings/SettingFields.vue'
|
||||||
import { showToast } from '@/utils'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
fields: {
|
fields: {
|
||||||
@@ -51,7 +50,9 @@ const props = defineProps({
|
|||||||
|
|
||||||
const update = () => {
|
const update = () => {
|
||||||
props.fields.forEach((f) => {
|
props.fields.forEach((f) => {
|
||||||
if (f.type != 'Column Break') {
|
if (f.type == 'Upload') {
|
||||||
|
props.data.doc[f.name] = f.value ? f.value.file_url : null
|
||||||
|
} else if (f.type != 'Column Break') {
|
||||||
props.data.doc[f.name] = f.value
|
props.data.doc[f.name] = f.value
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -59,7 +60,7 @@ const update = () => {
|
|||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast(__('Error'), err.messages?.[0] || err, 'x')
|
toast.error(err.messages?.[0] || err)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -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,21 +55,32 @@
|
|||||||
<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 w-[10rem] py-5"
|
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 :src="data[field.name]?.file_url" class="h-6 rounded" />
|
<img
|
||||||
|
:src="data[field.name]?.file_url || data[field.name]"
|
||||||
|
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">
|
||||||
<span class="break-all text-ink-gray-9">
|
<span class="break-all text-ink-gray-9">
|
||||||
{{ data[field.name]?.file_name }}
|
{{
|
||||||
|
data[field.name]?.file_name ||
|
||||||
|
data[field.name].split('/').pop()
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm text-ink-gray-5 mt-1">
|
<span
|
||||||
|
v-if="data[field.name]?.file_size"
|
||||||
|
class="text-sm text-ink-gray-5 mt-1"
|
||||||
|
>
|
||||||
{{ getFileSize(data[field.name]?.file_size) }}
|
{{ getFileSize(data[field.name]?.file_size) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<X
|
<X
|
||||||
@click="data[field.name] = null"
|
@click="data[field.name] = null"
|
||||||
class="bg-surface-gray-5 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
class="border text-ink-gray-7 border-outline-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,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>
|
||||||
@@ -99,7 +112,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { FormControl, FileUploader, Button, Switch } from 'frappe-ui'
|
import { FormControl, FileUploader, Button, Switch } from 'frappe-ui'
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { getFileSize, validateFile } from '@/utils'
|
import { getFileSize, validateFile } from '@/utils'
|
||||||
import { X } from 'lucide-vue-next'
|
import { X } from 'lucide-vue-next'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dialog v-model="show" :options="{ size: '4xl' }">
|
<Dialog v-model="show" :options="{ size: '5xl' }">
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex h-[calc(100vh_-_8rem)]">
|
<div class="flex h-[calc(100vh_-_8rem)]">
|
||||||
<div class="flex w-52 shrink-0 flex-col bg-surface-gray-2 p-2">
|
<div class="flex w-52 shrink-0 flex-col bg-surface-gray-2 p-2">
|
||||||
@@ -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,40 +326,16 @@ 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: 'Assignment Submission Template',
|
|
||||||
name: 'assignment_submission_template',
|
|
||||||
doctype: 'Email Template',
|
|
||||||
type: 'Link',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'Signup',
|
label: 'Signup',
|
||||||
icon: 'LogIn',
|
icon: 'LogIn',
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
label: 'Identify User Persona',
|
label: 'Identify User Category',
|
||||||
name: 'user_category',
|
name: 'user_category',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
description:
|
description:
|
||||||
'Enable this option to identify the user persona during signup.',
|
'Enable this option to identify the user category during signup.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Disable signup',
|
label: 'Disable signup',
|
||||||
@@ -341,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',
|
||||||
@@ -350,6 +356,37 @@ const tabsStructure = computed(() => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'SEO',
|
||||||
|
icon: 'Search',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Meta Description',
|
||||||
|
name: 'meta_description',
|
||||||
|
type: 'textarea',
|
||||||
|
rows: 4,
|
||||||
|
description:
|
||||||
|
"This description will be shown on lists and pages that don't have meta description",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Meta Keywords',
|
||||||
|
name: 'meta_keywords',
|
||||||
|
type: 'textarea',
|
||||||
|
rows: 4,
|
||||||
|
description:
|
||||||
|
'Comma separated keywords for search engines to find your website.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Column Break',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Meta Image',
|
||||||
|
name: 'meta_image',
|
||||||
|
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'
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="text-lg font-semibold">
|
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||||
{{ __('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') }}
|
||||||
@@ -17,9 +14,9 @@
|
|||||||
<div v-if="upcoming_evals.data?.length">
|
<div v-if="upcoming_evals.data?.length">
|
||||||
<div class="grid grid-cols-3 gap-4">
|
<div class="grid grid-cols-3 gap-4">
|
||||||
<div v-for="evl in upcoming_evals.data">
|
<div v-for="evl in upcoming_evals.data">
|
||||||
<div class="border rounded-md p-3">
|
<div class="border text-ink-gray-7 rounded-md p-3">
|
||||||
<div class="flex justify-between mb-3">
|
<div class="flex justify-between mb-3">
|
||||||
<span class="font-semibold leading-5">
|
<span class="font-semibold text-ink-gray-9 leading-5">
|
||||||
{{ evl.course_title }}
|
{{ evl.course_title }}
|
||||||
</span>
|
</span>
|
||||||
<Menu
|
<Menu
|
||||||
@@ -42,7 +39,7 @@
|
|||||||
leave-to-class="transform scale-95 opacity-0"
|
leave-to-class="transform scale-95 opacity-0"
|
||||||
>
|
>
|
||||||
<MenuItems
|
<MenuItems
|
||||||
class="absolute mt-2 w-32 rounded-md bg-white shadow-lg p-1.5"
|
class="absolute mt-2 w-32 rounded-md bg-surface-white border p-1.5"
|
||||||
>
|
>
|
||||||
<MenuItem v-slot="{ active }">
|
<MenuItem v-slot="{ active }">
|
||||||
<Button
|
<Button
|
||||||
@@ -82,12 +79,11 @@
|
|||||||
{{ evl.evaluator_name }}
|
{{ evl.evaluator_name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between space-x-2 mt-4">
|
<div
|
||||||
<Button
|
|
||||||
v-if="evl.google_meet_link"
|
v-if="evl.google_meet_link"
|
||||||
@click="openEvalCall(evl)"
|
class="flex items-center justify-between space-x-2 mt-4"
|
||||||
class="w-full"
|
|
||||||
>
|
>
|
||||||
|
<Button @click="openEvalCall(evl)" class="w-full">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<HeadsetIcon class="w-4 h-4 stroke-1.5" />
|
<HeadsetIcon class="w-4 h-4 stroke-1.5" />
|
||||||
</template>
|
</template>
|
||||||
@@ -119,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'
|
||||||
@@ -164,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?'),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user