Compare commits
488 Commits
feature-pa
...
ENCOA-131_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d0cb8eefb | ||
|
|
58448a391f | ||
|
|
f6550e6a36 | ||
|
|
cfe297cc38 | ||
|
|
4530e4079f | ||
|
|
de35e1a8b7 | ||
|
|
a6bf53e84c | ||
|
|
d7ffdc3031 | ||
|
|
98f2527fed | ||
|
|
d8bf10eaea | ||
|
|
f9216637df | ||
|
|
08945bfbdd | ||
|
|
b92a4285c9 | ||
|
|
271ca7069e | ||
|
|
08ed8fcb32 | ||
|
|
17f678a3ac | ||
|
|
6bd9816edd | ||
|
|
77ac15c2bb | ||
|
|
55cc9765e2 | ||
|
|
e433a150a9 | ||
|
|
a61ad2cc7e | ||
|
|
680f4cfa95 | ||
|
|
311824e8b7 | ||
|
|
2fb73cc3a3 | ||
|
|
70de97766e | ||
|
|
c7ff11d0fc | ||
|
|
e312af36bb | ||
|
|
ac980023b5 | ||
|
|
3b43803b7e | ||
|
|
c8be2f1255 | ||
|
|
b6b5f3a9f1 | ||
|
|
6ce81b300a | ||
|
|
3d3c4448ae | ||
|
|
cb2c1641f5 | ||
|
|
7bcd0f863f | ||
|
|
2d95cbd3dc | ||
|
|
49aac93618 | ||
|
|
28ad7944e0 | ||
|
|
d4553501b8 | ||
|
|
4654c21d92 | ||
|
|
becc91d8ea | ||
|
|
06cb4485f4 | ||
|
|
9b22fb259c | ||
|
|
f2137efaa0 | ||
|
|
00834fec7b | ||
|
|
ef84052909 | ||
|
|
6ea80dd0da | ||
|
|
5168e70edc | ||
|
|
a7719dbb55 | ||
|
|
25e6cb36a9 | ||
|
|
a7c1ea0409 | ||
|
|
8aed075553 | ||
|
|
3fc581aaac | ||
|
|
020f689af6 | ||
|
|
04c9ff24ea | ||
|
|
105c03fa09 | ||
|
|
547e0fc530 | ||
|
|
bf7793e103 | ||
|
|
60554d8e16 | ||
|
|
5d26af511c | ||
|
|
12104e797a | ||
|
|
d307c61cd7 | ||
|
|
6774b2d0b6 | ||
|
|
fa53382c08 | ||
|
|
67929655f4 | ||
|
|
e8c47941d0 | ||
|
|
82d0a0556f | ||
|
|
7cd18b07bb | ||
|
|
aca8ad2d14 | ||
|
|
4bb80919ad | ||
|
|
5ed851878a | ||
|
|
763452e3cc | ||
|
|
063a73691a | ||
|
|
caddf87231 | ||
|
|
0f38e01283 | ||
|
|
640b6f0e4d | ||
|
|
f43d562405 | ||
|
|
39752cbb1d | ||
|
|
229d93c03e | ||
|
|
b0ab8a8fce | ||
|
|
90cb705ad2 | ||
|
|
65fa6e64e6 | ||
|
|
7c0e7ef53e | ||
|
|
b6c3754b40 | ||
|
|
4e3c947d2a | ||
|
|
abcb1afd48 | ||
|
|
0b88d6bcd1 | ||
|
|
fef5bf44de | ||
|
|
2c43d48bbd | ||
|
|
4865b47393 | ||
|
|
3892fe1a67 | ||
|
|
39710aaea1 | ||
|
|
b57e11bec4 | ||
|
|
fdc8f98b21 | ||
|
|
2b71f2467c | ||
|
|
cd1caf0f53 | ||
|
|
3b77d3fc0b | ||
|
|
73525f1dc0 | ||
|
|
c256231cfc | ||
|
|
2fb41f7462 | ||
|
|
aa96b13ec2 | ||
|
|
f9429d1056 | ||
|
|
af9462398a | ||
|
|
6fd0b7aef3 | ||
|
|
bc47f9c001 | ||
|
|
4ea3a844ed | ||
|
|
ea8a3625ef | ||
|
|
3eb2f432fa | ||
|
|
5d10e6564d | ||
|
|
e518323d99 | ||
|
|
dbf262598f | ||
|
|
951ca5736e | ||
|
|
7960e7d8fb | ||
|
|
99039f8bf3 | ||
|
|
3c7df4e33c | ||
|
|
614a7a2a29 | ||
|
|
ec67f91263 | ||
|
|
aa4e13a18d | ||
|
|
23e26617e2 | ||
|
|
ef32226c6c | ||
|
|
c9174f37ef | ||
|
|
c99dbab4b6 | ||
|
|
eb985014be | ||
|
|
845bccbe2a | ||
|
|
3ec886c31d | ||
|
|
fa3929d5e9 | ||
|
|
b7940087b5 | ||
|
|
7fb0ed884c | ||
|
|
d93852e230 | ||
|
|
d04ea33616 | ||
|
|
eb38464aca | ||
|
|
cd85c71aec | ||
|
|
c464375414 | ||
|
|
82233c7d53 | ||
|
|
cc5be99b0f | ||
|
|
65dc3e92d0 | ||
|
|
addd117834 | ||
|
|
72b498eb85 | ||
|
|
0aba6355ed | ||
|
|
a0b8521f0a | ||
|
|
eb7c539a0e | ||
|
|
22928ce283 | ||
|
|
4a1a52bd61 | ||
|
|
af00f49adc | ||
|
|
8d37e60f5d | ||
|
|
74a53f55fd | ||
|
|
101605ad88 | ||
|
|
cf1b47fbd2 | ||
|
|
f9174c13c1 | ||
|
|
032d20b4b2 | ||
|
|
2146ef1a92 | ||
|
|
4928267036 | ||
|
|
f0f38b335f | ||
|
|
3e21538d02 | ||
|
|
33fd6ddf8f | ||
|
|
1bb5405894 | ||
|
|
44adc142f6 | ||
|
|
4379716e9b | ||
|
|
b4b078c8c9 | ||
|
|
6dbc2f5ed2 | ||
|
|
9a51096a94 | ||
|
|
1315e0b280 | ||
|
|
4505ea5ff8 | ||
|
|
192324b891 | ||
|
|
326d305a69 | ||
|
|
cfcff3cf3b | ||
|
|
202632ff58 | ||
|
|
7116892f9a | ||
|
|
c6f40f625b | ||
|
|
556f642112 | ||
|
|
22611121c6 | ||
|
|
720597e916 | ||
|
|
e74ded676e | ||
|
|
ee60eedd0d | ||
|
|
c37a1becbf | ||
|
|
b9cca483ec | ||
|
|
c758bdaf9e | ||
|
|
5ada588b16 | ||
|
|
eec1bb0c30 | ||
|
|
65f8368708 | ||
|
|
806e621c5b | ||
|
|
ce35b23714 | ||
|
|
2cd025b118 | ||
|
|
2e699d7e25 | ||
|
|
30da295c60 | ||
|
|
a82a399d52 | ||
|
|
505df31d6b | ||
|
|
a4d8ba72af | ||
|
|
2bfd0cb502 | ||
|
|
5ee071028c | ||
|
|
23b9452a3a | ||
|
|
0ce3a16d3a | ||
|
|
4315a7b17c | ||
|
|
247f192a0a | ||
|
|
9c944ae3d2 | ||
|
|
a390aa429d | ||
|
|
3367384791 | ||
|
|
158324a705 | ||
|
|
f9286d1793 | ||
|
|
2e376c37dd | ||
|
|
5bda9ed227 | ||
|
|
97b533bd3a | ||
|
|
75a45108a2 | ||
|
|
bfc0def20f | ||
|
|
9db33e6a51 | ||
|
|
ba5d926659 | ||
|
|
1cd4dfc397 | ||
|
|
bf5dd62b35 | ||
|
|
4e583d11b6 | ||
|
|
688505b4eb | ||
|
|
81b8ceb2b3 | ||
|
|
d93d36c392 | ||
|
|
3299acee36 | ||
|
|
abddead402 | ||
|
|
2d69fdac3c | ||
|
|
506ff2503e | ||
|
|
5d191730d2 | ||
|
|
346b131388 | ||
|
|
aba49e385f | ||
|
|
5789688eab | ||
|
|
f7da11bc69 | ||
|
|
10802f6bb5 | ||
|
|
37e356572b | ||
|
|
8669ef462d | ||
|
|
df1c0bad4d | ||
|
|
bcb1a0f914 | ||
|
|
bf1bdd935c | ||
|
|
edc9d4de2a | ||
|
|
229275aaee | ||
|
|
f0ff6ac691 | ||
|
|
878c7c2ef0 | ||
|
|
0a28c2bd41 | ||
|
|
38e48c90bb | ||
|
|
c6f35d7750 | ||
|
|
85f684dff5 | ||
|
|
d94a9bb88a | ||
|
|
1950d5f15d | ||
|
|
e84cc8ddd8 | ||
|
|
cf2fd06d39 | ||
|
|
b6015b6433 | ||
|
|
fea58a7b40 | ||
|
|
13284eab75 | ||
|
|
dd4e3a4694 | ||
|
|
eb55e65d91 | ||
|
|
cb75ba6056 | ||
|
|
859d9283a7 | ||
|
|
1a3437b333 | ||
|
|
bbbf17daa0 | ||
|
|
ae79aef132 | ||
|
|
c3e71b4389 | ||
|
|
2784117862 | ||
|
|
8162567e12 | ||
|
|
58300e32ff | ||
|
|
cb489bf0ca | ||
|
|
91bc91e725 | ||
|
|
ce086a8b22 | ||
|
|
6e71ee7cb0 | ||
|
|
21e58e3b9c | ||
|
|
b885dd46b5 | ||
|
|
0fc2df1070 | ||
|
|
cf91f1812d | ||
|
|
3289f27cd5 | ||
|
|
80939d16a5 | ||
|
|
11b5490af4 | ||
|
|
a31070d4a3 | ||
|
|
95c3f89911 | ||
|
|
2a58e0d33f | ||
|
|
afe59f5a3a | ||
|
|
7fd56357e0 | ||
|
|
a4a40b9145 | ||
|
|
48faee07f6 | ||
|
|
f0d7d7644b | ||
|
|
309dfba583 | ||
|
|
cf64a91651 | ||
|
|
0f47a8af70 | ||
|
|
d0310f7c2b | ||
|
|
f6a0a391b9 | ||
|
|
8dd4dad096 | ||
|
|
96baa2a6e0 | ||
|
|
8dd557a29b | ||
|
|
4e30eda06f | ||
|
|
12bb124d91 | ||
|
|
a71e6632d6 | ||
|
|
36f518afca | ||
|
|
a534126c61 | ||
|
|
752a46b247 | ||
|
|
663b1aae4f | ||
|
|
9b37b60be0 | ||
|
|
4347d0cabb | ||
|
|
0403773b8e | ||
|
|
8d99a6b03c | ||
|
|
02320b9484 | ||
|
|
fb077fd8cc | ||
|
|
b5a305485f | ||
|
|
8f5b27e9ce | ||
|
|
9ef04b822a | ||
|
|
a6160c3cf0 | ||
|
|
8f6639b7fc | ||
|
|
6a803fe137 | ||
|
|
d7f6a4dde7 | ||
|
|
6058e510de | ||
|
|
7208530879 | ||
|
|
9b6c545932 | ||
|
|
afb9071758 | ||
|
|
d50393930e | ||
|
|
03e1f2cfa3 | ||
|
|
877d2f359f | ||
|
|
45df9837e7 | ||
|
|
923319051c | ||
|
|
f6b4d6ad52 | ||
|
|
19d16c9cef | ||
|
|
daa27e41b3 | ||
|
|
916fa66446 | ||
|
|
10a3243756 | ||
|
|
a1c7f70329 | ||
|
|
bd2efb0ef5 | ||
|
|
34065f1f6e | ||
|
|
41873f80d7 | ||
|
|
a1b67c017d | ||
|
|
13fd7e1ee5 | ||
|
|
4996417218 | ||
|
|
60d436b5b9 | ||
|
|
8d39a20267 | ||
|
|
5d46d7e453 | ||
|
|
15f9fb320d | ||
|
|
494fc9bab6 | ||
|
|
0c5c024098 | ||
|
|
903a567805 | ||
|
|
df3929d5e6 | ||
|
|
6d62500596 | ||
|
|
e5e4e87752 | ||
|
|
0b3e686f3f | ||
|
|
3da87cce60 | ||
|
|
c9daba17e1 | ||
|
|
5cfd6d56a6 | ||
|
|
ec8c06ca94 | ||
|
|
77a22b3ab3 | ||
|
|
e79139174b | ||
|
|
61a86394ed | ||
|
|
f6741dd80e | ||
|
|
ce6708be6e | ||
|
|
b62cae2e3a | ||
|
|
d73b6d9d12 | ||
|
|
c11906a395 | ||
|
|
a29b0b56d9 | ||
|
|
53dbf99fba | ||
|
|
cb49e15cb0 | ||
|
|
0eddded560 | ||
|
|
11c6f70576 | ||
|
|
6712e89c47 | ||
|
|
9959cf4294 | ||
|
|
daec246835 | ||
|
|
8ea97ee944 | ||
|
|
975f4c8285 | ||
|
|
f0b85409c9 | ||
|
|
bdd862c633 | ||
|
|
4166781f7e | ||
|
|
1f8e9106de | ||
|
|
9e651358d5 | ||
|
|
5aed336c96 | ||
|
|
85b94512e9 | ||
|
|
906646ebce | ||
|
|
96108a4958 | ||
|
|
fb449f2054 | ||
|
|
d5ee3d9519 | ||
|
|
4e20ec6575 | ||
|
|
836b674076 | ||
|
|
5086c6fb09 | ||
|
|
489c9c3b7e | ||
|
|
e3ded29e77 | ||
|
|
16419a5584 | ||
|
|
3e3b24cc30 | ||
|
|
841698ba10 | ||
|
|
d50904611c | ||
|
|
e77fd16d26 | ||
|
|
649f24e4ae | ||
|
|
2f0cbfe74e | ||
|
|
d022bd078a | ||
|
|
c18afee9ad | ||
|
|
a65b72adad | ||
|
|
e13aea9f7d | ||
|
|
2920fa7f3a | ||
|
|
7af96ecccc | ||
|
|
70716b3483 | ||
|
|
d7bb64e7e0 | ||
|
|
dd19b5746c | ||
|
|
f967282f71 | ||
|
|
8b2459c304 | ||
|
|
72fb934d4f | ||
|
|
ed0b8bcb99 | ||
|
|
6f211d8435 | ||
|
|
b59589b855 | ||
|
|
db20feaa00 | ||
|
|
8fc2cf571e | ||
|
|
3128fea8c9 | ||
|
|
0e53b4a454 | ||
|
|
cbb61d18fe | ||
|
|
dff51cf6ea | ||
|
|
15dbadcc53 | ||
|
|
624a3fb88e | ||
|
|
00feee2179 | ||
|
|
0f8f9bc05b | ||
|
|
f76b7578a6 | ||
|
|
1a17689cd2 | ||
|
|
a958e2ff0d | ||
|
|
36b861266f | ||
|
|
771262fc18 | ||
|
|
0f03ce95e7 | ||
|
|
6a6e010daa | ||
|
|
13496387c4 | ||
|
|
4ecb21e0ae | ||
|
|
8663fe13bd | ||
|
|
de4638bc46 | ||
|
|
c9740fe8ee | ||
|
|
9b9b67c6cd | ||
|
|
fe2abaacae | ||
|
|
11e2ea3249 | ||
|
|
2de4b7c715 | ||
|
|
a8ffebe944 | ||
|
|
9ab7c3ed59 | ||
|
|
f374d91ef8 | ||
|
|
62ecc4e395 | ||
|
|
46764cacfa | ||
|
|
0b9e1bd734 | ||
|
|
bddb2ed18e | ||
|
|
e8fbeff77a | ||
|
|
b64593df90 | ||
|
|
2657cb409c | ||
|
|
329ed573b3 | ||
|
|
bb7558afb8 | ||
|
|
259ed03ee4 | ||
|
|
bf6c805487 | ||
|
|
1086e78936 | ||
|
|
7d0d930140 | ||
|
|
f02fff55e7 | ||
|
|
08e71c4dd8 | ||
|
|
6f5a74844c | ||
|
|
c4011cd456 | ||
|
|
5ef2568aa5 | ||
|
|
6d817e6d27 | ||
|
|
5decfb098d | ||
|
|
c2b6be4425 | ||
|
|
f320fee416 | ||
|
|
445e486cd2 | ||
|
|
ee26b50cf6 | ||
|
|
22f2b43692 | ||
|
|
29b2c8b3b8 | ||
|
|
51cc1e3f36 | ||
|
|
d9fce10538 | ||
|
|
bd74313bd5 | ||
|
|
18df890ef9 | ||
|
|
13ebb9bbd8 | ||
|
|
38c0c823e1 | ||
|
|
b50e15d1d9 | ||
|
|
969698d8b8 | ||
|
|
7d83ebc5c5 | ||
|
|
e99650ecd8 | ||
|
|
7287a9ce9a | ||
|
|
8cc7e6a57d | ||
|
|
0a24cb9978 | ||
|
|
a5c1286748 | ||
|
|
06684a4900 | ||
|
|
1823538058 | ||
|
|
60ccc822b5 | ||
|
|
9abd69c5e5 | ||
|
|
2667891bdd | ||
|
|
65485a0d1f | ||
|
|
74dd96d000 | ||
|
|
49ee3c45e5 | ||
|
|
49d2680a07 | ||
|
|
9dac7fd19e | ||
|
|
528299571c | ||
|
|
dcc630b8e5 | ||
|
|
be5125e5b0 | ||
|
|
0adf45c6ad | ||
|
|
d9b93a3470 | ||
|
|
83e4173750 | ||
|
|
e2d5f6ac9d | ||
|
|
37c3c6f7f4 | ||
|
|
3b4dfb9648 | ||
|
|
330c177ff9 | ||
|
|
0cff310354 | ||
|
|
87a1d7c288 | ||
|
|
8e1fe15a24 | ||
|
|
c95c0eff9b | ||
|
|
eaf94f458a | ||
|
|
ba85596e79 | ||
|
|
c6a478c406 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1,5 @@
|
|||||||
|
src/constants/test_firebase.json
|
||||||
|
|
||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
|
|||||||
@@ -54,4 +54,4 @@ EXPOSE 3000
|
|||||||
ENV PORT 3000
|
ENV PORT 3000
|
||||||
ENV HOSTNAME localhost
|
ENV HOSTNAME localhost
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
CMD HOSTNAME="0.0.0.0" node server.js
|
||||||
17
components.json
Normal file
17
components.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "src/styles/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": false,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000";
|
const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000";
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: false,
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
|
|||||||
11249
package-lock.json
generated
11249
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
37
package.json
37
package.json
@@ -11,30 +11,40 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@beam-australia/react-env": "^3.1.1",
|
"@beam-australia/react-env": "^3.1.1",
|
||||||
"@headlessui/react": "^1.7.13",
|
"@dnd-kit/core": "^6.1.0",
|
||||||
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
|
"@firebase/util": "^1.9.7",
|
||||||
|
"@headlessui/react": "^2.1.2",
|
||||||
"@mdi/js": "^7.1.96",
|
"@mdi/js": "^7.1.96",
|
||||||
"@mdi/react": "^1.6.1",
|
"@mdi/react": "^1.6.1",
|
||||||
"@next/font": "13.1.6",
|
|
||||||
"@paypal/paypal-js": "^7.1.0",
|
"@paypal/paypal-js": "^7.1.0",
|
||||||
"@paypal/react-paypal-js": "^8.1.3",
|
"@paypal/react-paypal-js": "^8.1.3",
|
||||||
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
|
"@radix-ui/react-popover": "^1.1.1",
|
||||||
"@react-pdf/renderer": "^3.1.14",
|
"@react-pdf/renderer": "^3.1.14",
|
||||||
|
"@react-spring/web": "^9.7.4",
|
||||||
"@tanstack/react-table": "^8.10.1",
|
"@tanstack/react-table": "^8.10.1",
|
||||||
"@types/node": "18.13.0",
|
"@types/node": "18.13.0",
|
||||||
"@types/react": "18.0.27",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "18.0.10",
|
"@types/react-dom": "^18.3.0",
|
||||||
"axios": "^1.3.5",
|
"@use-gesture/react": "^10.3.1",
|
||||||
|
"axios": "^1",
|
||||||
|
"axios-cache-interceptor": "^1",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"chart.js": "^4.2.1",
|
"chart.js": "^4.2.1",
|
||||||
"clsx": "^1.2.1",
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"countries-list": "^3.0.1",
|
"countries-list": "^3.0.1",
|
||||||
"country-codes-list": "^1.6.11",
|
"country-codes-list": "^1.6.11",
|
||||||
"currency-symbol-map": "^5.1.0",
|
"currency-symbol-map": "^5.1.0",
|
||||||
"daisyui": "^3.1.5",
|
"daisyui": "^3.1.5",
|
||||||
"eslint": "8.33.0",
|
"eslint": "8.33.0",
|
||||||
"eslint-config-next": "13.1.6",
|
"eslint-config-next": "13.1.6",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"express-handlebars": "^7.1.2",
|
"express-handlebars": "^7.1.2",
|
||||||
"firebase": "9.19.1",
|
"firebase": "9.19.1",
|
||||||
"firebase-admin": "^11.10.1",
|
"firebase-admin": "^11.10.1",
|
||||||
|
"firebase-scrypt": "^2.2.0",
|
||||||
"formidable": "^3.5.0",
|
"formidable": "^3.5.0",
|
||||||
"formidable-serverless": "^1.1.1",
|
"formidable-serverless": "^1.1.1",
|
||||||
"framer-motion": "^9.0.2",
|
"framer-motion": "^9.0.2",
|
||||||
@@ -43,7 +53,7 @@
|
|||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"moment-timezone": "^0.5.44",
|
"moment-timezone": "^0.5.44",
|
||||||
"next": "13.1.6",
|
"next": "^14.2.5",
|
||||||
"nodemailer": "^6.9.5",
|
"nodemailer": "^6.9.5",
|
||||||
"nodemailer-express-handlebars": "^6.1.0",
|
"nodemailer-express-handlebars": "^6.1.0",
|
||||||
"primeicons": "^6.0.1",
|
"primeicons": "^6.0.1",
|
||||||
@@ -58,7 +68,7 @@
|
|||||||
"react-diff-viewer": "^3.1.1",
|
"react-diff-viewer": "^3.1.1",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-firebase-hooks": "^5.1.1",
|
"react-firebase-hooks": "^5.1.1",
|
||||||
"react-icons": "^4.8.0",
|
"react-icons": "^5.3.0",
|
||||||
"react-lineto": "^3.3.0",
|
"react-lineto": "^3.3.0",
|
||||||
"react-media-recorder": "1.6.5",
|
"react-media-recorder": "1.6.5",
|
||||||
"react-phone-number-input": "^3.3.6",
|
"react-phone-number-input": "^3.3.6",
|
||||||
@@ -66,12 +76,15 @@
|
|||||||
"react-select": "^5.7.5",
|
"react-select": "^5.7.5",
|
||||||
"react-string-replace": "^1.1.0",
|
"react-string-replace": "^1.1.0",
|
||||||
"react-toastify": "^9.1.2",
|
"react-toastify": "^9.1.2",
|
||||||
|
"react-tooltip": "^5.27.1",
|
||||||
"react-xarrows": "^2.0.2",
|
"react-xarrows": "^2.0.2",
|
||||||
"read-excel-file": "^5.7.1",
|
"read-excel-file": "^5.7.1",
|
||||||
"short-unique-id": "^5.0.2",
|
"short-unique-id": "5.0.2",
|
||||||
"stripe": "^13.10.0",
|
"stripe": "^13.10.0",
|
||||||
"swr": "^2.1.3",
|
"swr": "^2.2.5",
|
||||||
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwind-scrollbar-hide": "^1.1.7",
|
"tailwind-scrollbar-hide": "^1.1.7",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "4.9.5",
|
"typescript": "4.9.5",
|
||||||
"use-file-picker": "^2.1.0",
|
"use-file-picker": "^2.1.0",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
@@ -79,6 +92,7 @@
|
|||||||
"zustand": "^4.3.6"
|
"zustand": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@simbathesailor/use-what-changed": "^2.0.0",
|
||||||
"@types/blob-stream": "^0.1.33",
|
"@types/blob-stream": "^0.1.33",
|
||||||
"@types/formidable": "^3.4.0",
|
"@types/formidable": "^3.4.0",
|
||||||
"@types/howler": "^2.2.11",
|
"@types/howler": "^2.2.11",
|
||||||
@@ -94,7 +108,6 @@
|
|||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
"husky": "^8.0.3",
|
"husky": "^8.0.3",
|
||||||
"postcss": "^8.4.21",
|
"postcss": "^8.4.21",
|
||||||
"tailwindcss": "^3.2.4",
|
"tailwindcss": "^3.2.4"
|
||||||
"types/": "paypal/react-paypal-js"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/manuals/corporate.pdf
Normal file
BIN
public/manuals/corporate.pdf
Normal file
Binary file not shown.
BIN
public/manuals/student.pdf
Normal file
BIN
public/manuals/student.pdf
Normal file
Binary file not shown.
BIN
public/manuals/teacher.pdf
Normal file
BIN
public/manuals/teacher.pdf
Normal file
Binary file not shown.
1
public/mat-icon-info.svg
Normal file
1
public/mat-icon-info.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M440-280h80v-240h-80v240Zm40-320q17 0 28.5-11.5T520-640q0-17-11.5-28.5T480-680q-17 0-28.5 11.5T440-640q0 17 11.5 28.5T480-600Zm0 520q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 535 B |
BIN
public/orange-stock-photo.jpg
Normal file
BIN
public/orange-stock-photo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/red-stock-photo.jpg
Normal file
BIN
public/red-stock-photo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 MiB |
193
src/components/AIDetection.tsx
Normal file
193
src/components/AIDetection.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import RadialProgressBar from "./RadialProgressBar";
|
||||||
|
import { AIDetectionAttributes } from "@/interfaces/exam";
|
||||||
|
import { Tooltip } from 'react-tooltip';
|
||||||
|
import SegmentedProgressBar from "./SegmentedProgressBar";
|
||||||
|
|
||||||
|
|
||||||
|
// Colors and texts scrapped from gpt's zero react bundle
|
||||||
|
const AIDetection: React.FC<AIDetectionAttributes> = ({ predicted_class, confidence_category, class_probabilities, sentences }) => {
|
||||||
|
const probabilityTooltipContent = `
|
||||||
|
Encoach's deep learning model predicts the <br/>
|
||||||
|
probability this text has been entirely <br/>
|
||||||
|
generated by AI. For instance, a 40% AI <br/>
|
||||||
|
probability does not indicate that the text<br/>
|
||||||
|
contains 40% AI-written content. Rather, it<br/>
|
||||||
|
indicates the text is more likely to be partially<br/>
|
||||||
|
human written than be entirely AI-written.
|
||||||
|
`;
|
||||||
|
const confidenceTooltipContent = `
|
||||||
|
Confidence scores are a safeguard to better<br/>
|
||||||
|
understand AI identification results. Encoach<br/>
|
||||||
|
trained it's deep learning model on a diverse<br/>
|
||||||
|
dataset of millions of human and AI-written<br/>
|
||||||
|
documents. Green scores indicate that you can scan<br/>
|
||||||
|
with confidence that the model has classified<br/>
|
||||||
|
many similar documents with high accuracy.<br/>
|
||||||
|
Red scores indicate that this text is dissimilar<br/>
|
||||||
|
to the ones in their training set, which can impact<br/>
|
||||||
|
the model's accuracy, and to proceed with caution.
|
||||||
|
`;
|
||||||
|
const confidenceKeywords = ["moderately", "highly", "confident", "uncertain"];
|
||||||
|
var confidence = {
|
||||||
|
low: {
|
||||||
|
ai: "Encoach is uncertain about this text. If Encoach had to classify it, it would be considered",
|
||||||
|
human: "Encoach is uncertain about this text. If Encoach had to classify it, it would likely be considered",
|
||||||
|
mixed: "Encoach is uncertain about this text. If Encoach had to classify it, it would likely be a"
|
||||||
|
},
|
||||||
|
medium: {
|
||||||
|
ai: "Encoach is moderately confident this text was",
|
||||||
|
human: "Encoach is moderately confident this text is entirely",
|
||||||
|
mixed: "Encoach is moderately confident this text is a"
|
||||||
|
},
|
||||||
|
high: {
|
||||||
|
ai: "Encoach is highly confident this text was",
|
||||||
|
human: "Encoach is highly confident this text is entirely",
|
||||||
|
mixed: "Encoach is highly confident this text is a"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var classPrediction = {
|
||||||
|
ai: {
|
||||||
|
background: "bg-ai-detection-result-ai-bg",
|
||||||
|
color: "text-ai-detection-result-ai",
|
||||||
|
text: "ai generated"
|
||||||
|
},
|
||||||
|
mixed: {
|
||||||
|
background: "bg-ai-detection-result-mixed-bg",
|
||||||
|
color: "text-ai-detection-result-mixed",
|
||||||
|
text: "mix of ai and human"
|
||||||
|
},
|
||||||
|
human: {
|
||||||
|
background: "bg-ai-detection-result-human-bg",
|
||||||
|
color: "text-ai-detection-result-human",
|
||||||
|
text: "human"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const segments = [
|
||||||
|
{ percentage: Math.round(class_probabilities["human"] * 100), subtitle: 'human', color: "ai-detection-result-human" },
|
||||||
|
{ percentage: Math.round(class_probabilities["mixed"] * 100), subtitle: 'mixed', color: "ai-detection-result-mixed" },
|
||||||
|
{ percentage: Math.round(class_probabilities["ai"] * 100), subtitle: 'ai', color: "ai-detection-result-ai" }
|
||||||
|
];
|
||||||
|
const styleConfidenceText = (text: string): [string, string[]] => {
|
||||||
|
const keywords: string[] = [];
|
||||||
|
const styledText = text.split(" ").map(word => {
|
||||||
|
if (confidenceKeywords.includes(word)) {
|
||||||
|
keywords.push(word);
|
||||||
|
return `<span style="font-weight: 500; text-decoration: underline;">${word}</span>`;
|
||||||
|
}
|
||||||
|
return word
|
||||||
|
}).join(" ");
|
||||||
|
return [styledText, keywords];
|
||||||
|
};
|
||||||
|
const confidenceText = confidence[confidence_category][predicted_class];
|
||||||
|
const [styledText, keywords] = styleConfidenceText(confidenceText);
|
||||||
|
const tooltipStyle = {
|
||||||
|
"backgroundColor": "rgb(255, 255, 255)",
|
||||||
|
"color": "#8992B1",
|
||||||
|
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||||
|
borderRadius: '0.125rem'
|
||||||
|
}
|
||||||
|
const highestProbability = Math.max(class_probabilities["ai"], class_probabilities["human"], class_probabilities["mixed"]);
|
||||||
|
const spanTextColor = highestProbability === class_probabilities["ai"]
|
||||||
|
? "#f4bf4f"
|
||||||
|
: highestProbability === class_probabilities["human"]
|
||||||
|
? "#50c08a"
|
||||||
|
: "#93aafb";
|
||||||
|
let spanClassName = highestProbability === class_probabilities["ai"]
|
||||||
|
? "text-ai-detection-result-ai"
|
||||||
|
: highestProbability === class_probabilities["human"]
|
||||||
|
? "text-ai-detection-result-human"
|
||||||
|
: "text-ai-detection-result-mixed";
|
||||||
|
spanClassName = `${spanClassName} font-bold text-lg`
|
||||||
|
const percentage = Math.round(highestProbability * 100)
|
||||||
|
const hasHighlightedForAI = sentences.some(item => item.highlight_sentence_for_ai);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip id="probability-tooltip" className="z-50 bg-white shadow-md rounded-sm" style={tooltipStyle} />
|
||||||
|
<Tooltip id="confidence-tooltip" className="z-50 bg-white shadow-md rounded-sm" style={tooltipStyle} />
|
||||||
|
<div className="flex flex-col bg-white p-6 rounded-lg shadow-lg gap-16">
|
||||||
|
<h1 className="text-lg font-semibold">Encoach Detection Results</h1>
|
||||||
|
<div className="flex flex-row -md:flex-col -lg:gap-0 -xl:gap-10 gap-20 items-stretch -md:items-center">
|
||||||
|
<div className="flex -md:w-5/6 w-1/2 justify-center">
|
||||||
|
<div className="flex flex-col border rounded-xl">
|
||||||
|
<h1 className="border-b p-6 font-medium">Text Classification</h1>
|
||||||
|
<div className="flex flex-row gap-8 items-center p-6">
|
||||||
|
<RadialProgressBar
|
||||||
|
percentage={percentage}
|
||||||
|
text={predicted_class}
|
||||||
|
color={spanTextColor}
|
||||||
|
spanClassName={spanClassName}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1 text-sm">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<span className="mr-2 text-ai-detection-result-ai-text font-semibold text-xl">
|
||||||
|
{`${Math.round(class_probabilities["ai"] * 100)}%`}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm -md:text-xs text-ai-detection-text">Probability AI generated</span>
|
||||||
|
<a data-tooltip-id="probability-tooltip" data-tooltip-html={probabilityTooltipContent} className='ml-1 flex items-center justify-center'>
|
||||||
|
<Image src="/mat-icon-info.svg" width={24} height={24} alt="Probability Tooltip" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-center gap-1">
|
||||||
|
<div className={clsx(
|
||||||
|
"rounded-full w-3 h-3",
|
||||||
|
confidence_category == 'low' ?
|
||||||
|
"bg-ai-detection-confidence-low border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-low-transparent"
|
||||||
|
)}></div>
|
||||||
|
<div className={clsx(
|
||||||
|
"rounded-full w-3 h-3",
|
||||||
|
confidence_category == 'medium' ?
|
||||||
|
"bg-ai-detection-confidence-medium border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-medium-transparent"
|
||||||
|
)}></div>
|
||||||
|
<div className={clsx(
|
||||||
|
"rounded-full w-3 h-3 mr-2",
|
||||||
|
confidence_category == 'high' ?
|
||||||
|
"bg-ai-detection-confidence-high border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-high-transparent"
|
||||||
|
)}></div>
|
||||||
|
<span className="text-sm -md:text-xs text-ai-detection-text">{keywords.join(' ')}</span>
|
||||||
|
<a data-tooltip-id="confidence-tooltip" data-tooltip-html={confidenceTooltipContent} className='ml-1 flex items-center justify-center'>
|
||||||
|
<Image src="/mat-icon-info.svg" width={24} height={24} alt="Probability Tooltip" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col border rounded-xl -md:w-5/6 w-2/6">
|
||||||
|
<h1 className="border-b p-6 font-medium">Probability Breakdown</h1>
|
||||||
|
<div className="flex items-center w-full h-full">
|
||||||
|
<SegmentedProgressBar segments={segments} className="w-full px-8 -md:py-8 text-ai-detection-text" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: styledText }} className="mr-2"></div>
|
||||||
|
<div className={clsx(
|
||||||
|
"flex items-center justify-center p-2 rounded",
|
||||||
|
classPrediction[predicted_class]['color'],
|
||||||
|
classPrediction[predicted_class]['background']
|
||||||
|
)}>
|
||||||
|
<span className="text-sm">{classPrediction[predicted_class]['text']}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(hasHighlightedForAI && <div>
|
||||||
|
Sentences that are likely written by AI are <span className="font-semibold bg-ai-detection-highlight">highlighted</span>.
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
</div >
|
||||||
|
<div>
|
||||||
|
{sentences.map((item, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={item.highlight_sentence_for_ai ? 'bg-ai-detection-highlight' : ''}
|
||||||
|
>
|
||||||
|
{item.sentence}{' '}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default AIDetection;
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import {Dialog, Transition} from "@headlessui/react";
|
|
||||||
import {Fragment} from "react";
|
|
||||||
import Button from "./Low/Button";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: (next?: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BlankQuestionsModal({isOpen, onClose}: Props) {
|
|
||||||
return (
|
|
||||||
<Transition show={isOpen} as={Fragment}>
|
|
||||||
<Dialog onClose={() => onClose(false)} className="relative z-50">
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0">
|
|
||||||
<div className="fixed inset-0 bg-black/30" />
|
|
||||||
</Transition.Child>
|
|
||||||
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95">
|
|
||||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
|
||||||
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
|
|
||||||
<Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title>
|
|
||||||
<span>
|
|
||||||
Please note that you are finishing the current module and once you proceed to the next module, you will no longer be
|
|
||||||
able to change the answers in the current one, including your unanswered questions. <br />
|
|
||||||
<br />
|
|
||||||
Are you sure you want to continue without completing those questions?
|
|
||||||
</span>
|
|
||||||
<div className="w-full flex justify-between mt-8">
|
|
||||||
<Button color="purple" onClick={() => onClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
|
|
||||||
Go Back
|
|
||||||
</Button>
|
|
||||||
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full">
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</div>
|
|
||||||
</Transition.Child>
|
|
||||||
</Dialog>
|
|
||||||
</Transition>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -21,14 +21,18 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DemographicInformationInput({user, mutateUser}: Props) {
|
export default function DemographicInformationInput({user, mutateUser}: Props) {
|
||||||
const [country, setCountry] = useState<string>();
|
const [country, setCountry] = useState(user.demographicInformation?.country);
|
||||||
const [phone, setPhone] = useState<string>();
|
const [phone, setPhone] = useState(user.demographicInformation?.phone);
|
||||||
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
|
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
|
||||||
const [gender, setGender] = useState<Gender>();
|
const [gender, setGender] = useState<Gender>();
|
||||||
const [employment, setEmployment] = useState<EmploymentStatus>();
|
const [employment, setEmployment] = useState<EmploymentStatus>();
|
||||||
const [position, setPosition] = useState<string>();
|
|
||||||
const [timezone, setTimezone] = useState<string>(moment.tz.guess());
|
const [timezone, setTimezone] = useState<string>(moment.tz.guess());
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [position, setPosition] = useState(
|
||||||
|
user.type === "corporate" || user.type === "mastercorporate"
|
||||||
|
? user.demographicInformation?.position
|
||||||
|
: user.demographicInformation?.employment,
|
||||||
|
);
|
||||||
|
|
||||||
const [companyName, setCompanyName] = useState<string>();
|
const [companyName, setCompanyName] = useState<string>();
|
||||||
const [commercialRegistration, setCommercialRegistration] = useState<string>();
|
const [commercialRegistration, setCommercialRegistration] = useState<string>();
|
||||||
@@ -85,7 +89,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
|||||||
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
||||||
<CountrySelect value={country} onChange={setCountry} />
|
<CountrySelect value={country} onChange={setCountry} />
|
||||||
</div>
|
</div>
|
||||||
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} placeholder="Enter phone number" required />
|
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} value={phone} placeholder="Enter phone number" required />
|
||||||
</div>
|
</div>
|
||||||
{user.type === "student" && (
|
{user.type === "student" && (
|
||||||
<Input
|
<Input
|
||||||
@@ -106,7 +110,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
|||||||
|
|
||||||
<GenderInput value={gender} onChange={setGender} />
|
<GenderInput value={gender} onChange={setGender} />
|
||||||
{user.type === "corporate" && (
|
{user.type === "corporate" && (
|
||||||
<Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required />
|
<Input name="position" onChange={setPosition} type="text" label="Department" placeholder="CEO, Head of Marketing..." required />
|
||||||
)}
|
)}
|
||||||
{user.type !== "corporate" && <EmploymentStatusInput value={employment} onChange={setEmployment} />}
|
{user.type !== "corporate" && <EmploymentStatusInput value={employment} onChange={setEmployment} />}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
84
src/components/Dropdown.tsx
Normal file
84
src/components/Dropdown.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import React, { useState, ReactNode, useRef, useEffect } from 'react';
|
||||||
|
import { animated, useSpring } from '@react-spring/web';
|
||||||
|
|
||||||
|
interface DropdownProps {
|
||||||
|
title: ReactNode;
|
||||||
|
open?: boolean;
|
||||||
|
className?: string;
|
||||||
|
contentWrapperClassName?: string;
|
||||||
|
bottomPadding?: number;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Dropdown: React.FC<DropdownProps> = ({
|
||||||
|
title,
|
||||||
|
open = false,
|
||||||
|
className = "w-full text-left font-semibold flex justify-between items-center p-4",
|
||||||
|
contentWrapperClassName = "px-6",
|
||||||
|
bottomPadding = 12,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState<boolean>(open);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [contentHeight, setContentHeight] = useState<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
if (contentRef.current) {
|
||||||
|
resizeObserver = new ResizeObserver(entries => {
|
||||||
|
for (let entry of entries) {
|
||||||
|
if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
|
||||||
|
const height = entry.borderBoxSize[0].blockSize;
|
||||||
|
setContentHeight(height + bottomPadding);
|
||||||
|
} else {
|
||||||
|
// Fallback for browsers that don't support borderBoxSize
|
||||||
|
const height = entry.contentRect.height;
|
||||||
|
setContentHeight(height + bottomPadding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(contentRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [bottomPadding]);
|
||||||
|
|
||||||
|
const springProps = useSpring({
|
||||||
|
height: isOpen ? contentHeight : 0,
|
||||||
|
opacity: isOpen ? 1 : 0,
|
||||||
|
config: { tension: 300, friction: 30 }
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 transform transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<animated.div style={springProps} className="overflow-hidden">
|
||||||
|
<div ref={contentRef} className={contentWrapperClassName} style={{paddingBottom: bottomPadding}}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</animated.div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dropdown;
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import {FillBlanksExercise} from "@/interfaces/exam";
|
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import {Fragment, useEffect, useState} from "react";
|
|
||||||
import reactStringReplace from "react-string-replace";
|
|
||||||
import {CommonProps} from ".";
|
|
||||||
import Button from "../Low/Button";
|
|
||||||
|
|
||||||
interface WordsDrawerProps {
|
|
||||||
words: {word: string; isDisabled: boolean}[];
|
|
||||||
isOpen: boolean;
|
|
||||||
blankId?: string;
|
|
||||||
previouslySelectedWord?: string;
|
|
||||||
onCancel: () => void;
|
|
||||||
onAnswer: (answer: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel, onAnswer}: WordsDrawerProps) {
|
|
||||||
const [selectedWord, setSelectedWord] = useState<string | undefined>(previouslySelectedWord);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"w-full h-full absolute top-0 left-0 bg-gradient-to-t from-mti-black to-transparent z-10",
|
|
||||||
isOpen ? "visible opacity-10" : "invisible opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"absolute w-full bg-white px-7 py-8 bottom-0 left-0 shadow-2xl rounded-2xl z-20 flex flex-col gap-8 transition-opacity duration-300 ease-in-out",
|
|
||||||
isOpen ? "visible opacity-100" : "invisible opacity-0",
|
|
||||||
)}>
|
|
||||||
<div className="w-full flex gap-2">
|
|
||||||
<div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-purple-light">{blankId}</div>
|
|
||||||
<span> Choose the correct word:</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-6 gap-6" key="word-array">
|
|
||||||
{words.map(({word, isDisabled}) => (
|
|
||||||
<button
|
|
||||||
key={`${word}_${blankId}`}
|
|
||||||
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
|
|
||||||
className={clsx(
|
|
||||||
"rounded-full py-3 text-center transition duration-300 ease-in-out",
|
|
||||||
selectedWord === word ? "text-white bg-mti-purple-light" : "bg-mti-purple-ultralight",
|
|
||||||
!isDisabled && "hover:text-white hover:bg-mti-purple",
|
|
||||||
"disabled:cursor-not-allowed disabled:text-mti-gray-dim",
|
|
||||||
)}
|
|
||||||
disabled={isDisabled}>
|
|
||||||
{word}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between w-full">
|
|
||||||
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
|
|
||||||
Confirm
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FillBlanks({
|
|
||||||
id,
|
|
||||||
allowRepetition,
|
|
||||||
type,
|
|
||||||
prompt,
|
|
||||||
solutions,
|
|
||||||
text,
|
|
||||||
words,
|
|
||||||
userSolutions,
|
|
||||||
onNext,
|
|
||||||
onBack,
|
|
||||||
}: FillBlanksExercise & CommonProps) {
|
|
||||||
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
|
|
||||||
const [currentBlankId, setCurrentBlankId] = useState<string>();
|
|
||||||
const [isDrawerShowing, setIsDrawerShowing] = useState(false);
|
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
|
||||||
const allBlanks = Array.from(text.match(/({{\d+}})/g) || []).map((x) => x.replaceAll("{", "").replaceAll("}", ""));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTimeout(() => setIsDrawerShowing(!!currentBlankId), 100);
|
|
||||||
}, [currentBlankId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [hasExamEnded]);
|
|
||||||
|
|
||||||
const calculateScore = () => {
|
|
||||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
|
||||||
const correct = answers.filter(
|
|
||||||
(x) => solutions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false,
|
|
||||||
).length;
|
|
||||||
const missing = total - answers.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
|
||||||
|
|
||||||
return {total, correct, missing};
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderLines = (line: string) => {
|
|
||||||
return (
|
|
||||||
<span className="text-base leading-5">
|
|
||||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
|
||||||
const id = match.replaceAll(/[\{\}]/g, "");
|
|
||||||
const userSolution = answers.find((x) => x.id === id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={clsx(
|
|
||||||
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
|
|
||||||
!userSolution && "w-6 h-6 text-center text-mti-purple-light bg-mti-purple-ultralight",
|
|
||||||
currentBlankId === id && "text-white !bg-mti-purple-light ",
|
|
||||||
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
|
|
||||||
)}
|
|
||||||
onClick={() => setCurrentBlankId(id)}>
|
|
||||||
{userSolution ? userSolution.solution : id}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
|
||||||
{(!!currentBlankId || isDrawerShowing) && (
|
|
||||||
<WordsDrawer
|
|
||||||
key={currentBlankId}
|
|
||||||
blankId={currentBlankId}
|
|
||||||
words={words.map((word) => ({word, isDisabled: allowRepetition ? false : answers.map((x) => x.solution).includes(word)}))}
|
|
||||||
previouslySelectedWord={currentBlankId ? answers.find((x) => x.id === currentBlankId)?.solution : undefined}
|
|
||||||
isOpen={isDrawerShowing}
|
|
||||||
onCancel={() => setCurrentBlankId(undefined)}
|
|
||||||
onAnswer={(solution: string) => {
|
|
||||||
setAnswers((prev) => [...prev.filter((x) => x.id !== currentBlankId), {id: currentBlankId!, solution}]);
|
|
||||||
if (allBlanks.findIndex((x) => x === currentBlankId) + 1 < allBlanks.length) {
|
|
||||||
setCurrentBlankId(allBlanks[allBlanks.findIndex((x) => x === currentBlankId) + 1]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCurrentBlankId(undefined);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="text-sm w-full leading-6">
|
|
||||||
{prompt.split("\\n").map((line, index) => (
|
|
||||||
<Fragment key={index}>
|
|
||||||
{line}
|
|
||||||
<br />
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
|
||||||
{text.split("\\n").map((line, index) => (
|
|
||||||
<p key={index}>
|
|
||||||
{renderLines(line)}
|
|
||||||
<br />
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
|
|
||||||
className="max-w-[200px] w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
84
src/components/Exercises/FillBlanks/MCDropdown.tsx
Normal file
84
src/components/Exercises/FillBlanks/MCDropdown.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import React, { useRef, useEffect, useState } from 'react';
|
||||||
|
import { animated, useSpring } from '@react-spring/web';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
interface MCDropdownProps {
|
||||||
|
id: string;
|
||||||
|
options: { [key: string]: string };
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
selectedValue?: string;
|
||||||
|
className?: string;
|
||||||
|
width: number;
|
||||||
|
isOpen: boolean;
|
||||||
|
onToggle: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MCDropdown: React.FC<MCDropdownProps> = ({
|
||||||
|
id,
|
||||||
|
options,
|
||||||
|
onSelect,
|
||||||
|
selectedValue,
|
||||||
|
className = "relative",
|
||||||
|
width,
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
}) => {
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [contentHeight, setContentHeight] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (contentRef.current) {
|
||||||
|
setContentHeight(contentRef.current.scrollHeight);
|
||||||
|
}
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
const springProps = useSpring({
|
||||||
|
height: isOpen ? contentHeight : 0,
|
||||||
|
opacity: isOpen ? 1 : 0,
|
||||||
|
config: { tension: 300, friction: 30 }
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${className} inline-block`} style={{ width: `${width}px` }}>
|
||||||
|
<button
|
||||||
|
onClick={() => onToggle(id)}
|
||||||
|
className={
|
||||||
|
clsx("rounded-full hover:text-white transition duration-300 ease-in-out px-5 py-2 text-center w-full flex items-center justify-between",
|
||||||
|
selectedValue ? "bg-mti-purple text-white" : "bg-mti-purple-ultralight text-mti-purple-light"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate p-1">{selectedValue || 'Select an option'}</span>
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 transform transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<animated.div
|
||||||
|
style={{ ...springProps, width: `${width}px` }}
|
||||||
|
className="absolute z-10 mt-1 overflow-hidden bg-white rounded-md shadow-lg"
|
||||||
|
>
|
||||||
|
<div ref={contentRef}>
|
||||||
|
{Object.entries(options).sort((a, b) => a[0].localeCompare(b[0])).map(([key, value]) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(value);
|
||||||
|
onToggle(id);
|
||||||
|
}}
|
||||||
|
className="p-4 hover:bg-mti-purple-ultralight cursor-pointer whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<span>{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</animated.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MCDropdown;
|
||||||
64
src/components/Exercises/FillBlanks/WordsDrawer.tsx
Normal file
64
src/components/Exercises/FillBlanks/WordsDrawer.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface WordsDrawerProps {
|
||||||
|
words: {word: string; isDisabled: boolean}[];
|
||||||
|
isOpen: boolean;
|
||||||
|
blankId?: string;
|
||||||
|
previouslySelectedWord?: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onAnswer: (answer: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const WordsDrawer: React.FC<WordsDrawerProps> = ({words, isOpen, blankId, previouslySelectedWord, onCancel, onAnswer}) => {
|
||||||
|
const [selectedWord, setSelectedWord] = useState<string | undefined>(previouslySelectedWord);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"w-full h-full absolute top-0 left-0 bg-gradient-to-t from-mti-black to-transparent z-10",
|
||||||
|
isOpen ? "visible opacity-10" : "invisible opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"absolute w-full bg-white px-7 py-8 bottom-0 left-0 shadow-2xl rounded-2xl z-20 flex flex-col gap-8 transition-opacity duration-300 ease-in-out",
|
||||||
|
isOpen ? "visible opacity-100" : "invisible opacity-0",
|
||||||
|
)}>
|
||||||
|
<div className="w-full flex gap-2">
|
||||||
|
<div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-purple-light">{blankId}</div>
|
||||||
|
<span> Choose the correct word:</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-6 gap-6" key="word-array">
|
||||||
|
{words.map(({word, isDisabled}) => (
|
||||||
|
<button
|
||||||
|
key={`${word}_${blankId}`}
|
||||||
|
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
|
||||||
|
className={clsx(
|
||||||
|
"rounded-full py-3 text-center transition duration-300 ease-in-out",
|
||||||
|
selectedWord === word ? "text-white bg-mti-purple-light" : "bg-mti-purple-ultralight",
|
||||||
|
!isDisabled && "hover:text-white hover:bg-mti-purple",
|
||||||
|
"disabled:cursor-not-allowed disabled:text-mti-gray-dim",
|
||||||
|
)}
|
||||||
|
disabled={isDisabled}>
|
||||||
|
{word}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between w-full">
|
||||||
|
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WordsDrawer;
|
||||||
239
src/components/Exercises/FillBlanks/index.tsx
Normal file
239
src/components/Exercises/FillBlanks/index.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import reactStringReplace from "react-string-replace";
|
||||||
|
import { CommonProps } from "..";
|
||||||
|
import Button from "../../Low/Button";
|
||||||
|
import { v4 } from "uuid";
|
||||||
|
import MCDropdown from "./MCDropdown";
|
||||||
|
|
||||||
|
const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
prompt,
|
||||||
|
solutions,
|
||||||
|
text,
|
||||||
|
words,
|
||||||
|
userSolutions,
|
||||||
|
variant,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
}) => {
|
||||||
|
const { shuffles, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state);
|
||||||
|
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
|
||||||
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
|
||||||
|
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const excludeWordMCType = (x: any) => {
|
||||||
|
return typeof x === "string" ? x : (x as { letter: string; word: string });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type });
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
|
let correctWords: any;
|
||||||
|
if (exam && (exam.module === "level" || exam.module === "reading") && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") {
|
||||||
|
correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setOpenDropdownId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const calculateScore = () => {
|
||||||
|
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||||
|
const correct = answers!.filter((x) => {
|
||||||
|
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
|
||||||
|
if (!solution) return false;
|
||||||
|
const option = correctWords!.find((w: any) => {
|
||||||
|
if (typeof w === "string") {
|
||||||
|
return w.toLowerCase() === x.solution.toLowerCase();
|
||||||
|
} else if ("letter" in w) {
|
||||||
|
return w.letter.toLowerCase() === x.solution.toLowerCase();
|
||||||
|
} else {
|
||||||
|
return w.id.toString() === x.id.toString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!option) return false;
|
||||||
|
|
||||||
|
if (typeof option === "string") {
|
||||||
|
return solution.toLowerCase() === option.toLowerCase();
|
||||||
|
} else if ("letter" in option) {
|
||||||
|
return solution.toLowerCase() === option.word.toLowerCase();
|
||||||
|
} else if ("options" in option) {
|
||||||
|
return option.options[solution as keyof typeof option.options] == x.solution;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}).length;
|
||||||
|
const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||||
|
return { total, correct, missing };
|
||||||
|
};
|
||||||
|
|
||||||
|
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const renderLines = useCallback(
|
||||||
|
(line: string) => {
|
||||||
|
return (
|
||||||
|
<div className="text-xl leading-5" key={v4()} ref={dropdownRef}>
|
||||||
|
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||||
|
const id = match.replaceAll(/[\{\}]/g, "");
|
||||||
|
const userSolution = answers.find((x) => x.id === id);
|
||||||
|
const styles = clsx(
|
||||||
|
"rounded-full hover:text-white transition duration-300 ease-in-out my-1 px-5 py-2 text-center w-fit",
|
||||||
|
!userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
|
||||||
|
userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight",
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentSelection = words.find((x) => {
|
||||||
|
if (typeof x !== "string" && "id" in x) {
|
||||||
|
return (x as FillBlanksMCOption).id.toString() == id.toString();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}) as FillBlanksMCOption;
|
||||||
|
|
||||||
|
return variant === "mc" ? (
|
||||||
|
<MCDropdown
|
||||||
|
id={id}
|
||||||
|
options={currentSelection.options}
|
||||||
|
onSelect={(value) => onSelection(id, value)}
|
||||||
|
selectedValue={userSolution?.solution}
|
||||||
|
className="inline-block py-2 px-1"
|
||||||
|
width={220}
|
||||||
|
isOpen={openDropdownId === id}
|
||||||
|
onToggle={()=> setOpenDropdownId(prevId => prevId === id ? null : id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
className={styles}
|
||||||
|
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])}
|
||||||
|
value={userSolution?.solution}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div >
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[variant, words, answers, openDropdownId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const memoizedLines = useMemo(() => {
|
||||||
|
return text.split("\\n").map((line, index) => (
|
||||||
|
<p key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}>
|
||||||
|
{renderLines(line)}
|
||||||
|
<br />
|
||||||
|
</p>
|
||||||
|
));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [text, variant, renderLines]);
|
||||||
|
|
||||||
|
const onSelection = (questionID: string, value: string) => {
|
||||||
|
setAnswers((prev) => [...prev.filter((x) => x.id !== questionID), { id: questionID, solution: value }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (variant === "mc") {
|
||||||
|
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [answers]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps })}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
||||||
|
Previous Page
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => {
|
||||||
|
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
||||||
|
}}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
|
{variant !== "mc" && (
|
||||||
|
<span className="text-sm w-full leading-6">
|
||||||
|
{prompt.split("\\n").map((line, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">{memoizedLines}</span>
|
||||||
|
{variant !== "mc" && (
|
||||||
|
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-4">
|
||||||
|
<span className="font-medium text-mti-purple-dark">Options</span>
|
||||||
|
<div className="flex gap-4 flex-wrap">
|
||||||
|
{words.map((v) => {
|
||||||
|
v = excludeWordMCType(v);
|
||||||
|
const text = typeof v === "string" ? v : `${v.letter} - ${v.word}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300",
|
||||||
|
!!answers.find(
|
||||||
|
(x) =>
|
||||||
|
x.solution.toLowerCase() ===
|
||||||
|
(typeof v === "string" ? v : "letter" in v ? v.letter : "").toLowerCase(),
|
||||||
|
) && "bg-mti-purple-dark text-white",
|
||||||
|
)}
|
||||||
|
key={v4()}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps })}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
||||||
|
Previous Page
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => {
|
||||||
|
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
||||||
|
}}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FillBlanks;
|
||||||
@@ -16,12 +16,12 @@ const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mo
|
|||||||
export default function InteractiveSpeaking({
|
export default function InteractiveSpeaking({
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
|
first_title,
|
||||||
|
second_title,
|
||||||
examID,
|
examID,
|
||||||
text,
|
|
||||||
type,
|
type,
|
||||||
prompts,
|
prompts,
|
||||||
userSolutions,
|
userSolutions,
|
||||||
updateIndex,
|
|
||||||
onNext,
|
onNext,
|
||||||
onBack,
|
onBack,
|
||||||
}: InteractiveSpeakingExercise & CommonProps) {
|
}: InteractiveSpeakingExercise & CommonProps) {
|
||||||
@@ -36,31 +36,6 @@ export default function InteractiveSpeaking({
|
|||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
const saveToStorage = async (previousURL?: string) => {
|
|
||||||
if (mediaBlob && mediaBlob.startsWith("blob")) {
|
|
||||||
const blobBuffer = await downloadBlob(mediaBlob);
|
|
||||||
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
|
|
||||||
|
|
||||||
const seed = Math.random().toString().replace("0.", "");
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("audio", audioFile, `${seed}.wav`);
|
|
||||||
formData.append("root", "speaking_recordings");
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "audio/wav",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await axios.post<{path: string}>("/api/storage/insert", formData, config);
|
|
||||||
if (previousURL && !previousURL.startsWith("blob")) await axios.post("/api/storage/delete", {path: previousURL});
|
|
||||||
return response.data.path;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const back = async () => {
|
const back = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
@@ -91,7 +66,9 @@ export default function InteractiveSpeaking({
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
setQuestionIndex(0);
|
||||||
|
|
||||||
onNext({
|
onNext({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
@@ -103,7 +80,6 @@ export default function InteractiveSpeaking({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userSolutions.length > 0 && answers.length === 0) {
|
if (userSolutions.length > 0 && answers.length === 0) {
|
||||||
console.log(userSolutions);
|
|
||||||
const solutions = userSolutions as unknown as typeof answers;
|
const solutions = userSolutions as unknown as typeof answers;
|
||||||
setAnswers(solutions);
|
setAnswers(solutions);
|
||||||
|
|
||||||
@@ -112,14 +88,6 @@ export default function InteractiveSpeaking({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [userSolutions, mediaBlob, answers]);
|
}, [userSolutions, mediaBlob, answers]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log({answers});
|
|
||||||
}, [answers]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (updateIndex) updateIndex(questionIndex);
|
|
||||||
}, [questionIndex, updateIndex]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) {
|
if (hasExamEnded) {
|
||||||
const answer = {
|
const answer = {
|
||||||
@@ -159,13 +127,10 @@ export default function InteractiveSpeaking({
|
|||||||
}, [answers, questionIndex]);
|
}, [answers, questionIndex]);
|
||||||
|
|
||||||
const saveAnswer = async (index: number) => {
|
const saveAnswer = async (index: number) => {
|
||||||
const previousURL = answers.find((x) => x.questionIndex === questionIndex)?.blob;
|
|
||||||
const audioPath = await saveToStorage(previousURL);
|
|
||||||
|
|
||||||
const answer = {
|
const answer = {
|
||||||
questionIndex,
|
questionIndex,
|
||||||
prompt: prompts[questionIndex].text,
|
prompt: prompts[questionIndex].text,
|
||||||
blob: audioPath ? audioPath : mediaBlob!,
|
blob: mediaBlob!,
|
||||||
};
|
};
|
||||||
|
|
||||||
setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]);
|
setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]);
|
||||||
@@ -187,139 +152,8 @@ export default function InteractiveSpeaking({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full gap-9">
|
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||||
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
<div className="flex justify-between w-full gap-8">
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<span className="font-semibold">{title}</span>
|
|
||||||
</div>
|
|
||||||
{prompts && prompts.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-4 w-full items-center">
|
|
||||||
<video key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
|
|
||||||
<source src={prompts[questionIndex].video_url} />
|
|
||||||
</video>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ReactMediaRecorder
|
|
||||||
audio
|
|
||||||
key={questionIndex}
|
|
||||||
onStop={(blob) => setMediaBlob(blob)}
|
|
||||||
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
|
|
||||||
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
|
||||||
<p className="text-base font-normal">Record your answer:</p>
|
|
||||||
<div className="flex gap-8 items-center justify-center py-8">
|
|
||||||
{status === "idle" && !mediaBlob && (
|
|
||||||
<>
|
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
|
||||||
{status === "idle" && (
|
|
||||||
<BsMicFill
|
|
||||||
onClick={() => {
|
|
||||||
setRecordingDuration(0);
|
|
||||||
startRecording();
|
|
||||||
setIsRecording(true);
|
|
||||||
}}
|
|
||||||
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{status === "recording" && (
|
|
||||||
<>
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<span className="text-xs w-9">
|
|
||||||
{Math.floor(recordingDuration / 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
:
|
|
||||||
{Math.floor(recordingDuration % 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<BsPauseCircle
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(false);
|
|
||||||
pauseRecording();
|
|
||||||
}}
|
|
||||||
className="text-red-500 w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<BsCheckCircleFill
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(false);
|
|
||||||
stopRecording();
|
|
||||||
}}
|
|
||||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{status === "paused" && (
|
|
||||||
<>
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<span className="text-xs w-9">
|
|
||||||
{Math.floor(recordingDuration / 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
:
|
|
||||||
{Math.floor(recordingDuration % 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<BsPlayCircle
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(true);
|
|
||||||
resumeRecording();
|
|
||||||
}}
|
|
||||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<BsCheckCircleFill
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(false);
|
|
||||||
stopRecording();
|
|
||||||
}}
|
|
||||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
|
|
||||||
<>
|
|
||||||
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<BsTrashFill
|
|
||||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
|
||||||
onClick={() => {
|
|
||||||
setRecordingDuration(0);
|
|
||||||
clearBlobUrl();
|
|
||||||
setMediaBlob(undefined);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BsMicFill
|
|
||||||
onClick={() => {
|
|
||||||
clearBlobUrl();
|
|
||||||
setRecordingDuration(0);
|
|
||||||
startRecording();
|
|
||||||
setIsRecording(true);
|
|
||||||
setMediaBlob(undefined);
|
|
||||||
}}
|
|
||||||
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8">
|
|
||||||
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
@@ -327,6 +161,148 @@ export default function InteractiveSpeaking({
|
|||||||
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col h-full w-full gap-9">
|
||||||
|
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<span className="font-semibold">{!!first_title && !!second_title ? `${first_title} & ${second_title}` : title}</span>
|
||||||
|
</div>
|
||||||
|
{prompts && prompts.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-4 w-full items-center">
|
||||||
|
<video key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
|
||||||
|
<source src={prompts[questionIndex].video_url} />
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReactMediaRecorder
|
||||||
|
audio
|
||||||
|
key={questionIndex}
|
||||||
|
onStop={(blob) => setMediaBlob(blob)}
|
||||||
|
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
|
||||||
|
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||||
|
<p className="text-base font-normal">Record your answer:</p>
|
||||||
|
<div className="flex gap-8 items-center justify-center py-8">
|
||||||
|
{status === "idle" && (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
{status === "idle" && (
|
||||||
|
<BsMicFill
|
||||||
|
onClick={() => {
|
||||||
|
setRecordingDuration(0);
|
||||||
|
startRecording();
|
||||||
|
setIsRecording(true);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "recording" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<span className="text-xs w-9">
|
||||||
|
{Math.floor(recordingDuration / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(recordingDuration % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsPauseCircle
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
pauseRecording();
|
||||||
|
}}
|
||||||
|
className="text-red-500 w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<BsCheckCircleFill
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
stopRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "paused" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<span className="text-xs w-9">
|
||||||
|
{Math.floor(recordingDuration / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(recordingDuration % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsPlayCircle
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(true);
|
||||||
|
resumeRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<BsCheckCircleFill
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
stopRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "stopped" && mediaBlobUrl && (
|
||||||
|
<>
|
||||||
|
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsTrashFill
|
||||||
|
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||||
|
onClick={() => {
|
||||||
|
setRecordingDuration(0);
|
||||||
|
clearBlobUrl();
|
||||||
|
setMediaBlob(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BsMicFill
|
||||||
|
onClick={() => {
|
||||||
|
clearBlobUrl();
|
||||||
|
setRecordingDuration(0);
|
||||||
|
startRecording();
|
||||||
|
setIsRecording(true);
|
||||||
|
setMediaBlob(undefined);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="self-end flex justify-between w-full gap-8">
|
||||||
|
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
|
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
||||||
import {MatchSentencesExercise} from "@/interfaces/exam";
|
import {MatchSentenceExerciseOption, MatchSentenceExerciseSentence, MatchSentencesExercise} from "@/interfaces/exam";
|
||||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
||||||
import Icon from "@mdi/react";
|
import Icon from "@mdi/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@@ -9,13 +9,81 @@ import {CommonProps} from ".";
|
|||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import Xarrow from "react-xarrows";
|
import Xarrow from "react-xarrows";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import {DndContext, DragEndEvent, useDraggable, useDroppable} from "@dnd-kit/core";
|
||||||
|
|
||||||
|
function DroppableQuestionArea({question, answer}: {question: MatchSentenceExerciseSentence; answer?: string}) {
|
||||||
|
const {isOver, setNodeRef} = useDroppable({id: `droppable_sentence_${question.id}`});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-4" ref={setNodeRef}>
|
||||||
|
<div className="flex items-center gap-3 cursor-pointer col-span-2">
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
)}>
|
||||||
|
{question.id}
|
||||||
|
</button>
|
||||||
|
<span>{question.sentence}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
key={`answer_${question.id}_${answer}`}
|
||||||
|
className={clsx("w-48 h-10 border rounded-xl flex items-center justify-center", isOver && "border-mti-purple-light")}>
|
||||||
|
{answer && `Paragraph ${answer}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraggableOptionArea({option}: {option: MatchSentenceExerciseOption}) {
|
||||||
|
const {attributes, listeners, setNodeRef, transform} = useDraggable({
|
||||||
|
id: `draggable_option_${option.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = transform
|
||||||
|
? {
|
||||||
|
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||||
|
zIndex: 99,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx("flex items-center justify-start gap-6 cursor-pointer")} ref={setNodeRef} style={style} {...listeners} {...attributes}>
|
||||||
|
<button
|
||||||
|
id={`option_${option.id}`}
|
||||||
|
// onClick={() => selectOption(id)}
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple px-3 py-2 rounded-full z-10",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
option.id,
|
||||||
|
)}>
|
||||||
|
Paragraph {option.id}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function MatchSentences({id, options, type, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
|
export default function MatchSentences({id, options, type, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
|
||||||
const [selectedQuestion, setSelectedQuestion] = useState<string>();
|
|
||||||
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
|
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [answers, setAnswers]);
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
if (event.over && event.over.id.toString().startsWith("droppable")) {
|
||||||
|
const optionID = event.active.id.toString().replace("draggable_option_", "");
|
||||||
|
const sentenceID = event.over.id.toString().replace("droppable_sentence_", "");
|
||||||
|
|
||||||
|
setAnswers((prev) => [...prev.filter((x) => x.question.toString() !== sentenceID), {question: sentenceID, option: optionID}]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = sentences.length;
|
const total = sentences.length;
|
||||||
const correct = answers.filter(
|
const correct = answers.filter(
|
||||||
@@ -26,20 +94,31 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectOption = (option: string) => {
|
|
||||||
if (!selectedQuestion) return;
|
|
||||||
setAnswers((prev) => [...prev.filter((x) => x.question !== selectedQuestion), {question: selectedQuestion, option}]);
|
|
||||||
setSelectedQuestion(undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [hasExamEnded]);
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
@@ -48,46 +127,28 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
|
||||||
<div className="flex flex-col gap-4">
|
<DndContext onDragEnd={handleDragEnd}>
|
||||||
{sentences.map(({sentence, id}) => (
|
<div className="flex flex-col gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
||||||
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
|
<div className="flex flex-col gap-4">
|
||||||
<span>{sentence} </span>
|
{sentences.map((question) => (
|
||||||
<button
|
<DroppableQuestionArea
|
||||||
id={id}
|
key={`question_${question.id}`}
|
||||||
onClick={() => setSelectedQuestion((prev) => (prev === id ? undefined : id))}
|
question={question}
|
||||||
className={clsx(
|
answer={answers.find((x) => x.question.toString() === question.id.toString())?.option}
|
||||||
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
/>
|
||||||
"transition duration-300 ease-in-out",
|
))}
|
||||||
selectedQuestion === id && "!text-white !bg-mti-purple",
|
</div>
|
||||||
id,
|
<div className="flex flex-col gap-4">
|
||||||
)}>
|
<span>Drag one of these paragraphs into the slots above:</span>
|
||||||
{id}
|
<div className="flex gap-4 flex-wrap justify-center items-center max-w-lg">
|
||||||
</button>
|
{options.map((option) => (
|
||||||
|
<DraggableOptionArea key={`answer_${option.id}`} option={option} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
</DndContext>
|
||||||
{options.map(({sentence, id}) => (
|
|
||||||
<div key={`answer_${id}`} className={clsx("flex items-center justify-start gap-2 cursor-pointer")}>
|
|
||||||
<button
|
|
||||||
id={id}
|
|
||||||
onClick={() => selectOption(id)}
|
|
||||||
className={clsx(
|
|
||||||
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
id,
|
|
||||||
)}>
|
|
||||||
{id}
|
|
||||||
</button>
|
|
||||||
<span>{sentence}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{answers.map((solution, index) => (
|
|
||||||
<Xarrow key={index} start={solution.question} end={solution.option} lineColor="#7872BF" showHead={false} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
@@ -106,6 +167,6 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,67 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
import {MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap} from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
import reactStringReplace from "react-string-replace";
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
|
import {v4} from "uuid";
|
||||||
|
|
||||||
function Question({
|
function Question({
|
||||||
|
id,
|
||||||
variant,
|
variant,
|
||||||
prompt,
|
prompt,
|
||||||
options,
|
options,
|
||||||
userSolution,
|
userSolution,
|
||||||
onSelectOption,
|
onSelectOption,
|
||||||
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
}: MultipleChoiceQuestion & {
|
||||||
|
userSolution: string | undefined;
|
||||||
|
onSelectOption?: (option: string) => void;
|
||||||
|
showSolution?: boolean;
|
||||||
|
}) {
|
||||||
|
const renderPrompt = (prompt: string) => {
|
||||||
|
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
|
||||||
|
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
|
||||||
|
return word.length > 0 ? <u key={v4()}>{word}</u> : null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-10">
|
<div className="flex flex-col gap-8">
|
||||||
<span className="">{prompt}</span>
|
{isNaN(Number(id)) ? (
|
||||||
|
<span className="text-lg">{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-lg">
|
||||||
|
<>
|
||||||
|
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
|
||||||
|
</>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div className="flex flex-wrap gap-4 justify-between">
|
<div className="flex flex-wrap gap-4 justify-between">
|
||||||
{variant === "image" &&
|
{variant === "image" &&
|
||||||
options.map((option) => (
|
options.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={option.id.toString()}
|
key={v4()}
|
||||||
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative",
|
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative select-none",
|
||||||
userSolution === option.id.toString() && "border-mti-purple-light",
|
userSolution === option.id.toString() && "border-mti-purple-light",
|
||||||
)}>
|
)}>
|
||||||
<span className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>{option.id.toString()}</span>
|
<span key={v4()} className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>
|
||||||
|
{option.id.toString()}
|
||||||
|
</span>
|
||||||
<img src={option.src!} alt={`Option ${option.id.toString()}`} />
|
<img src={option.src!} alt={`Option ${option.id.toString()}`} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{variant === "text" &&
|
{variant === "text" &&
|
||||||
options.map((option) => (
|
options.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={option.id.toString()}
|
key={v4()}
|
||||||
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm",
|
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none",
|
||||||
userSolution === option.id.toString() && "border-mti-purple-light",
|
userSolution === option.id.toString() && "!bg-mti-purple-light !text-white",
|
||||||
)}>
|
)}>
|
||||||
<span className="font-semibold">{option.id.toString()}.</span>
|
<span className="font-semibold">{option.id.toString()}.</span>
|
||||||
<span>{option.text}</span>
|
<span>{option.text}</span>
|
||||||
@@ -48,95 +72,150 @@ function Question({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MultipleChoice({
|
export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
||||||
id,
|
|
||||||
prompt,
|
|
||||||
type,
|
|
||||||
questions,
|
|
||||||
userSolutions,
|
|
||||||
updateIndex,
|
|
||||||
onNext,
|
|
||||||
onBack,
|
|
||||||
}: MultipleChoiceExercise & CommonProps) {
|
|
||||||
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||||
|
|
||||||
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
const {questionIndex, exerciseIndex, exam, shuffles, hasExamEnded, partIndex, setQuestionIndex, setCurrentSolution} = useExamStore(
|
||||||
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
|
(state) => state,
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
);
|
||||||
|
|
||||||
|
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
|
||||||
|
|
||||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setUserSolutions([...storeUserSolutions.filter((x) => x.exercise !== id), {exercise: id, solutions: answers, score: calculateScore(), type}]);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [answers]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [hasExamEnded]);
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
useEffect(() => {
|
const onSelectOption = (option: string, question: MultipleChoiceQuestion) => {
|
||||||
if (updateIndex) updateIndex(questionIndex);
|
|
||||||
}, [questionIndex, updateIndex]);
|
|
||||||
|
|
||||||
const onSelectOption = (option: string) => {
|
|
||||||
const question = questions[questionIndex];
|
|
||||||
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
|
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [answers, setAnswers]);
|
||||||
|
|
||||||
|
const getShuffledSolution = (originalSolution: string, questionShuffleMap: ShuffleMap) => {
|
||||||
|
for (const [newPosition, originalPosition] of Object.entries(questionShuffleMap.map)) {
|
||||||
|
if (originalPosition === originalSolution) {
|
||||||
|
return newPosition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return originalSolution;
|
||||||
|
};
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = questions.length;
|
const total = questions.length;
|
||||||
const correct = answers.filter(
|
const correct = answers.filter((x) => {
|
||||||
(x) => questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
|
const matchingQuestion = questions.find((y) => {
|
||||||
).length;
|
return y.id.toString() === x.question.toString();
|
||||||
const missing = total - answers.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length;
|
});
|
||||||
|
|
||||||
|
let isSolutionCorrect;
|
||||||
|
if (!shuffleMaps) {
|
||||||
|
isSolutionCorrect = matchingQuestion?.solution === x.option;
|
||||||
|
} else {
|
||||||
|
const shuffleMap = shuffleMaps.find((map) => map.questionID == x.question);
|
||||||
|
if (shuffleMap) {
|
||||||
|
isSolutionCorrect = getShuffledSolution(x.option, shuffleMap) == matchingQuestion?.solution;
|
||||||
|
} else {
|
||||||
|
isSolutionCorrect = matchingQuestion?.solution === x.option;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return isSolutionCorrect || false;
|
||||||
|
}).length;
|
||||||
|
const missing = total - answers!.filter((x) => questions.find((y) => x.question.toString() === y.id.toString())).length;
|
||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
if (questionIndex === questions.length - 1) {
|
if (questionIndex + 1 >= questions.length - 1) {
|
||||||
onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
onNext({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
|
||||||
} else {
|
} else {
|
||||||
setQuestionIndex(questionIndex + 1);
|
setQuestionIndex(questionIndex + 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
};
|
};
|
||||||
|
|
||||||
const back = () => {
|
const back = () => {
|
||||||
if (questionIndex === 0) {
|
if (questionIndex === 0) {
|
||||||
onBack({exercise: id, solutions: answers, score: calculateScore(), type});
|
onBack({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
|
||||||
} else {
|
} else {
|
||||||
setQuestionIndex(questionIndex - 1);
|
if (exam?.module === "level" && typeof exam.parts[0].intro !== "undefined" && questionIndex === 0) return;
|
||||||
|
setQuestionIndex(questionIndex - 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-2 mt-4 h-fit mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8">
|
<div className="flex justify-between w-full gap-8">
|
||||||
<span className="text-xl font-semibold">{prompt}</span>
|
<Button
|
||||||
{questionIndex < questions.length && (
|
color="purple"
|
||||||
<Question
|
variant="outline"
|
||||||
{...questions[questionIndex]}
|
onClick={back}
|
||||||
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
|
className="max-w-[200px] w-full"
|
||||||
onSelectOption={onSelectOption}
|
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
|
||||||
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full">
|
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
Next
|
{exam &&
|
||||||
|
exam.module === "level" &&
|
||||||
|
partIndex === exam.parts.length - 1 &&
|
||||||
|
exerciseIndex === exam.parts[partIndex].exercises.length - 1 &&
|
||||||
|
questionIndex + 1 >= questions.length - 1
|
||||||
|
? "Submit"
|
||||||
|
: "Next"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
|
<div className="flex flex-col gap-4 mt-4 mb-20">
|
||||||
|
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
|
{/*<span className="text-xl font-semibold mb-2">{"Select the appropriate option."}</span>*/}
|
||||||
|
{questionIndex < questions.length && (
|
||||||
|
<Question
|
||||||
|
{...questions[questionIndex]}
|
||||||
|
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
|
||||||
|
onSelectOption={(option) => onSelectOption(option, questions[questionIndex])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{questionIndex + 1 < questions.length && (
|
||||||
|
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
|
<Question
|
||||||
|
{...questions[questionIndex + 1]}
|
||||||
|
userSolution={answers.find((x) => questions[questionIndex + 1].id === x.question)?.option}
|
||||||
|
onSelectOption={(option) => onSelectOption(option, questions[questionIndex + 1])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={back}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
|
{exam &&
|
||||||
|
exam.module === "level" &&
|
||||||
|
partIndex === exam.parts.length - 1 &&
|
||||||
|
exerciseIndex === exam.parts[partIndex].exercises.length - 1 &&
|
||||||
|
questionIndex + 1 >= questions.length - 1
|
||||||
|
? "Submit"
|
||||||
|
: "Next"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,18 +7,21 @@ import Button from "../Low/Button";
|
|||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {downloadBlob} from "@/utils/evaluation";
|
import {downloadBlob} from "@/utils/evaluation";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
|
||||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||||
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function Speaking({id, title, text, video_url, type, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
export default function Speaking({id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||||
const [audioURL, setAudioURL] = useState<string>();
|
const [audioURL, setAudioURL] = useState<string>();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isPromptsModalOpen, setIsPromptsModalOpen] = useState(false);
|
||||||
|
const [inputText, setInputText] = useState("");
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
@@ -74,188 +77,47 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
|
|||||||
}, [isRecording]);
|
}, [isRecording]);
|
||||||
|
|
||||||
const next = async () => {
|
const next = async () => {
|
||||||
setIsLoading(true);
|
|
||||||
const storagePath = await saveToStorage();
|
|
||||||
setIsLoading(false);
|
|
||||||
|
|
||||||
onNext({
|
onNext({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: storagePath ? [{id, solution: storagePath}] : [],
|
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||||
score: {correct: 100, total: 100, missing: 0},
|
score: {correct: 0, total: 100, missing: 0},
|
||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const back = async () => {
|
const back = async () => {
|
||||||
setIsLoading(true);
|
|
||||||
const storagePath = await saveToStorage();
|
|
||||||
setIsLoading(false);
|
|
||||||
|
|
||||||
onBack({
|
onBack({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: storagePath ? [{id, solution: storagePath}] : [],
|
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||||
score: {correct: 100, total: 100, missing: 0},
|
score: {correct: 0, total: 100, missing: 0},
|
||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNoteWriting = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const newText = e.target.value;
|
||||||
|
const words = newText.match(/\S+/g);
|
||||||
|
const wordCount = words ? words.length : 0;
|
||||||
|
|
||||||
|
if (wordCount <= 100) {
|
||||||
|
setInputText(newText);
|
||||||
|
} else {
|
||||||
|
let count = 0;
|
||||||
|
let lastIndex = 0;
|
||||||
|
const matches = newText.matchAll(/\S+/g);
|
||||||
|
for (const match of matches) {
|
||||||
|
count++;
|
||||||
|
if (count > 100) break;
|
||||||
|
lastIndex = match.index! + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInputText(newText.slice(0, lastIndex));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full gap-9">
|
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||||
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
|
<div className="flex justify-between w-full gap-8">
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<span className="font-semibold">{title}</span>
|
|
||||||
{!video_url && (
|
|
||||||
<span className="font-regular">
|
|
||||||
{text.split("\\n").map((line, index) => (
|
|
||||||
<Fragment key={index}>
|
|
||||||
<span>{line}</span>
|
|
||||||
<br />
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-6">
|
|
||||||
{video_url && (
|
|
||||||
<div className="flex flex-col gap-4 w-full items-center">
|
|
||||||
<video key={id} autoPlay controls className="max-w-3xl rounded-xl">
|
|
||||||
<source src={video_url} />
|
|
||||||
</video>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{prompts && prompts.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<span className="font-bold">You should talk about the following things:</span>
|
|
||||||
<div className="flex flex-col gap-1 ml-4">
|
|
||||||
{prompts.map((x, index) => (
|
|
||||||
<li className="italic" key={index}>
|
|
||||||
{x}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ReactMediaRecorder
|
|
||||||
audio
|
|
||||||
onStop={(blob) => setMediaBlob(blob)}
|
|
||||||
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
|
|
||||||
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
|
||||||
<p className="text-base font-normal">Record your answer:</p>
|
|
||||||
<div className="flex gap-8 items-center justify-center py-8">
|
|
||||||
{status === "idle" && !mediaBlob && (
|
|
||||||
<>
|
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
|
||||||
{status === "idle" && (
|
|
||||||
<BsMicFill
|
|
||||||
onClick={() => {
|
|
||||||
setRecordingDuration(0);
|
|
||||||
startRecording();
|
|
||||||
setIsRecording(true);
|
|
||||||
}}
|
|
||||||
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{status === "recording" && (
|
|
||||||
<>
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<span className="text-xs w-9">
|
|
||||||
{Math.floor(recordingDuration / 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
:
|
|
||||||
{Math.floor(recordingDuration % 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<BsPauseCircle
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(false);
|
|
||||||
pauseRecording();
|
|
||||||
}}
|
|
||||||
className="text-red-500 w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<BsCheckCircleFill
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(false);
|
|
||||||
stopRecording();
|
|
||||||
}}
|
|
||||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{status === "paused" && (
|
|
||||||
<>
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<span className="text-xs w-9">
|
|
||||||
{Math.floor(recordingDuration / 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
:
|
|
||||||
{Math.floor(recordingDuration % 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<BsPlayCircle
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(true);
|
|
||||||
resumeRecording();
|
|
||||||
}}
|
|
||||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<BsCheckCircleFill
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(false);
|
|
||||||
stopRecording();
|
|
||||||
}}
|
|
||||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
|
|
||||||
<>
|
|
||||||
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<BsTrashFill
|
|
||||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
|
||||||
onClick={() => {
|
|
||||||
setRecordingDuration(0);
|
|
||||||
clearBlobUrl();
|
|
||||||
setMediaBlob(undefined);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BsMicFill
|
|
||||||
onClick={() => {
|
|
||||||
clearBlobUrl();
|
|
||||||
setRecordingDuration(0);
|
|
||||||
startRecording();
|
|
||||||
setIsRecording(true);
|
|
||||||
setMediaBlob(undefined);
|
|
||||||
}}
|
|
||||||
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8">
|
|
||||||
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
@@ -263,6 +125,193 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col h-full w-full gap-9">
|
||||||
|
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 w-full h-full">
|
||||||
|
<div className="flex flex-col gap-1 ml-4">
|
||||||
|
{prompts.map((x, index) => (
|
||||||
|
<li className="italic" key={index}>
|
||||||
|
{x}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!!suffix && <span className="font-bold">{suffix}</span>}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-0">
|
||||||
|
<span className="font-semibold">{title}</span>
|
||||||
|
{prompts.length > 0 && (
|
||||||
|
<span className="font-semibold">
|
||||||
|
You should talk for at least 1 minute and 30 seconds for your answer to be valid.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!video_url && (
|
||||||
|
<span className="font-regular">
|
||||||
|
{text.split("\\n").map((line, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<span>{line}</span>
|
||||||
|
<br />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-6 items-center">
|
||||||
|
{video_url && (
|
||||||
|
<div className="flex flex-col gap-4 w-full items-center">
|
||||||
|
<video key={id} autoPlay controls className="max-w-3xl rounded-xl">
|
||||||
|
<source src={video_url} />
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{prompts && prompts.length > 0 && <Button onClick={() => setIsPromptsModalOpen(true)}>View Prompts</Button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{prompts && prompts.length > 0 && (
|
||||||
|
<div className="w-full h-full flex flex-col gap-4">
|
||||||
|
<textarea
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
className="w-full h-full min-h-[200px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
||||||
|
onChange={handleNoteWriting}
|
||||||
|
value={inputText}
|
||||||
|
placeholder="Write your notes here..."
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<span className="text-base self-end text-mti-gray-cool">Word Count: {(inputText.match(/\S+/g) || []).length}/100</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ReactMediaRecorder
|
||||||
|
audio
|
||||||
|
onStop={(blob) => setMediaBlob(blob)}
|
||||||
|
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
|
||||||
|
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||||
|
<p className="text-base font-normal">Record your answer:</p>
|
||||||
|
<div className="flex gap-8 items-center justify-center py-8">
|
||||||
|
{status === "idle" && !mediaBlob && (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
{status === "idle" && (
|
||||||
|
<BsMicFill
|
||||||
|
onClick={() => {
|
||||||
|
setRecordingDuration(0);
|
||||||
|
startRecording();
|
||||||
|
setIsRecording(true);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "recording" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<span className="text-xs w-9">
|
||||||
|
{Math.floor(recordingDuration / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(recordingDuration % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsPauseCircle
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
pauseRecording();
|
||||||
|
}}
|
||||||
|
className="text-red-500 w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<BsCheckCircleFill
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
stopRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "paused" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<span className="text-xs w-9">
|
||||||
|
{Math.floor(recordingDuration / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(recordingDuration % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsPlayCircle
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(true);
|
||||||
|
resumeRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<BsCheckCircleFill
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
stopRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
|
||||||
|
<>
|
||||||
|
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsTrashFill
|
||||||
|
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||||
|
onClick={() => {
|
||||||
|
setRecordingDuration(0);
|
||||||
|
clearBlobUrl();
|
||||||
|
setMediaBlob(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BsMicFill
|
||||||
|
onClick={() => {
|
||||||
|
clearBlobUrl();
|
||||||
|
setRecordingDuration(0);
|
||||||
|
startRecording();
|
||||||
|
setIsRecording(true);
|
||||||
|
setMediaBlob(undefined);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="self-end flex justify-between w-full gap-8">
|
||||||
|
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
const [answers, setAnswers] = useState<{id: string; solution: "true" | "false" | "not_given"}[]>(userSolutions);
|
const [answers, setAnswers] = useState<{id: string; solution: "true" | "false" | "not_given"}[]>(userSolutions);
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
@@ -28,6 +29,11 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [answers, setAnswers]);
|
||||||
|
|
||||||
const toggleAnswer = (solution: "true" | "false" | "not_given", questionId: string) => {
|
const toggleAnswer = (solution: "true" | "false" | "not_given", questionId: string) => {
|
||||||
const answer = answers.find((x) => x.id === questionId);
|
const answer = answers.find((x) => x.id === questionId);
|
||||||
if (answer && answer.solution === solution) {
|
if (answer && answer.solution === solution) {
|
||||||
@@ -39,8 +45,25 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
@@ -116,6 +139,6 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ function Blank({
|
|||||||
export default function WriteBlanks({id, prompt, type, maxWords, solutions, userSolutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
|
export default function WriteBlanks({id, prompt, type, maxWords, solutions, userSolutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
|
||||||
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
|
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const {hasExamEnded, setCurrentSolution} = useExamStore((state) => state);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
@@ -70,6 +70,11 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
|
|||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [answers, setAnswers]);
|
||||||
|
|
||||||
const renderLines = (line: string) => {
|
const renderLines = (line: string) => {
|
||||||
return (
|
return (
|
||||||
<span className="text-base leading-5">
|
<span className="text-base leading-5">
|
||||||
@@ -87,8 +92,25 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<span key={index}>
|
<span key={index}>
|
||||||
@@ -123,6 +145,6 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,34 @@ export default function Writing({
|
|||||||
}, [inputText, wordCounter]);
|
}, [inputText, wordCounter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
disabled={!isSubmitEnabled}
|
||||||
|
onClick={() =>
|
||||||
|
onNext({
|
||||||
|
exercise: id,
|
||||||
|
solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}],
|
||||||
|
score: {correct: 100, total: 100, missing: 0},
|
||||||
|
type,
|
||||||
|
module: "writing",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{attachment && (
|
{attachment && (
|
||||||
<Transition show={isModalOpen} as={Fragment}>
|
<Transition show={isModalOpen} as={Fragment}>
|
||||||
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
||||||
@@ -170,6 +197,6 @@ export default function Writing({
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentenc
|
|||||||
|
|
||||||
export interface CommonProps {
|
export interface CommonProps {
|
||||||
examID?: string;
|
examID?: string;
|
||||||
updateIndex?: (internalIndex: number) => void;
|
|
||||||
onNext: (userSolutions: UserSolution) => void;
|
onNext: (userSolutions: UserSolution) => void;
|
||||||
onBack: (userSolutions: UserSolution) => void;
|
onBack: (userSolutions: UserSolution) => void;
|
||||||
}
|
}
|
||||||
@@ -33,7 +32,6 @@ export const renderExercise = (
|
|||||||
examID: string,
|
examID: string,
|
||||||
onNext: (userSolutions: UserSolution) => void,
|
onNext: (userSolutions: UserSolution) => void,
|
||||||
onBack: (userSolutions: UserSolution) => void,
|
onBack: (userSolutions: UserSolution) => void,
|
||||||
updateIndex?: (internalIndex: number) => void,
|
|
||||||
) => {
|
) => {
|
||||||
switch (exercise.type) {
|
switch (exercise.type) {
|
||||||
case "fillBlanks":
|
case "fillBlanks":
|
||||||
@@ -43,16 +41,7 @@ export const renderExercise = (
|
|||||||
case "matchSentences":
|
case "matchSentences":
|
||||||
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||||
case "multipleChoice":
|
case "multipleChoice":
|
||||||
return (
|
return <MultipleChoice key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||||
<MultipleChoice
|
|
||||||
key={exercise.id}
|
|
||||||
{...(exercise as MultipleChoiceExercise)}
|
|
||||||
updateIndex={updateIndex}
|
|
||||||
onNext={onNext}
|
|
||||||
onBack={onBack}
|
|
||||||
examID={examID}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "writeBlanks":
|
case "writeBlanks":
|
||||||
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||||
case "writing":
|
case "writing":
|
||||||
@@ -65,7 +54,6 @@ export const renderExercise = (
|
|||||||
key={exercise.id}
|
key={exercise.id}
|
||||||
{...(exercise as InteractiveSpeakingExercise)}
|
{...(exercise as InteractiveSpeakingExercise)}
|
||||||
examID={examID}
|
examID={examID}
|
||||||
updateIndex={updateIndex}
|
|
||||||
onNext={onNext}
|
onNext={onNext}
|
||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
/>
|
/>
|
||||||
|
|||||||
130
src/components/Generation/fill.blanks.edit.tsx
Normal file
130
src/components/Generation/fill.blanks.edit.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
|
||||||
|
import React from "react";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
exercise: FillBlanksExercise;
|
||||||
|
updateExercise: (data: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FillBlanksEdit = (props: Props) => {
|
||||||
|
const { exercise, updateExercise } = props;
|
||||||
|
|
||||||
|
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
|
||||||
|
return Array.isArray(words) && words.every((word) => word && typeof word === "object" && "id" in word && "options" in word);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
type={exercise?.variant && exercise.variant === "mc" ? "textarea" : "text"}
|
||||||
|
label="Prompt"
|
||||||
|
name="prompt"
|
||||||
|
required
|
||||||
|
value={exercise.prompt}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
prompt: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type={exercise?.variant && exercise.variant === "mc" ? "textarea" : "text"}
|
||||||
|
label="Text"
|
||||||
|
name="text"
|
||||||
|
required
|
||||||
|
value={exercise.text}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
text: exercise?.variant && exercise.variant === "mc" ? value : value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<h1 className="mt-4">Solutions</h1>
|
||||||
|
<div className="w-full flex flex-wrap -mx-2">
|
||||||
|
{exercise.solutions.map((solution, index) => (
|
||||||
|
<div key={solution.id} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label={`Solution ${index + 1}`}
|
||||||
|
name="solution"
|
||||||
|
required
|
||||||
|
value={solution.solution}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
solutions: exercise.solutions.map((sol) => (sol.id === solution.id ? { ...sol, solution: value } : sol)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-4">Words</h1>
|
||||||
|
<div className={clsx(exercise?.variant && exercise.variant === "mc" ? "w-full flex flex-row" : "w-full flex flex-wrap -mx-2")}>
|
||||||
|
{exercise?.variant && exercise.variant === "mc" && typeCheckWordsMC(exercise.words) ?
|
||||||
|
(
|
||||||
|
<div className="flex flex-col w-full">
|
||||||
|
{exercise.words.flatMap((mcOptions, wordIndex) =>
|
||||||
|
<>
|
||||||
|
<label className="font-semibold">{`Word ${wordIndex + 1}`}</label>
|
||||||
|
<div className="flex flex-row">
|
||||||
|
{Object.entries(mcOptions.options).map(([key, value], optionIndex) => (
|
||||||
|
<div key={`${wordIndex}-${optionIndex}-${key}`} className="flex sm:w-1/2 lg:w-1/4 px-2 mb-4">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label={`Option ${key}`}
|
||||||
|
name="word"
|
||||||
|
required
|
||||||
|
value={value}
|
||||||
|
onChange={(newValue) =>
|
||||||
|
updateExercise({
|
||||||
|
words: exercise.words.map((word, idx) =>
|
||||||
|
idx === wordIndex
|
||||||
|
? {
|
||||||
|
...(word as FillBlanksMCOption),
|
||||||
|
options: {
|
||||||
|
...(word as FillBlanksMCOption).options,
|
||||||
|
[key]: newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: word
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
:
|
||||||
|
(
|
||||||
|
exercise.words.map((word, index) => (
|
||||||
|
<div key={index} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label={`Word ${index + 1}`}
|
||||||
|
name="word"
|
||||||
|
required
|
||||||
|
value={typeof word === "string" ? word : ("word" in word ? word.word : "")}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
words: exercise.words.map((sol, idx) =>
|
||||||
|
index === idx ? (typeof word === "string" ? value : { ...word, word: value }) : sol,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FillBlanksEdit;
|
||||||
7
src/components/Generation/interactive.speaking.edit.tsx
Normal file
7
src/components/Generation/interactive.speaking.edit.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const InteractiveSpeakingEdit = () => {
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InteractiveSpeakingEdit;
|
||||||
130
src/components/Generation/match.sentences.edit.tsx
Normal file
130
src/components/Generation/match.sentences.edit.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { MatchSentencesExercise } from "@/interfaces/exam";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
exercise: MatchSentencesExercise;
|
||||||
|
updateExercise: (data: any) => void;
|
||||||
|
}
|
||||||
|
const MatchSentencesEdit = (props: Props) => {
|
||||||
|
const { exercise, updateExercise } = props;
|
||||||
|
|
||||||
|
const selectOptions = exercise.options.map((option) => ({
|
||||||
|
value: option.id,
|
||||||
|
label: option.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Prompt"
|
||||||
|
name="prompt"
|
||||||
|
required
|
||||||
|
value={exercise.prompt}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
prompt: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<h1>Solutions</h1>
|
||||||
|
<div className="w-full flex flex-wrap -mx-2">
|
||||||
|
{exercise.sentences.map((sentence, index) => (
|
||||||
|
<div key={sentence.id} className="flex flex-col w-full px-2">
|
||||||
|
<div className="flex w-full">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label={`Sentence ${index + 1}`}
|
||||||
|
name="sentence"
|
||||||
|
required
|
||||||
|
value={sentence.sentence}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
sentences: exercise.sentences.map((iSol) =>
|
||||||
|
iSol.id === sentence.id
|
||||||
|
? {
|
||||||
|
...iSol,
|
||||||
|
sentence: value,
|
||||||
|
}
|
||||||
|
: iSol
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="px-2"
|
||||||
|
/>
|
||||||
|
<div className="w-48 flex items-end px-2">
|
||||||
|
<Select
|
||||||
|
value={selectOptions.find(
|
||||||
|
(o) => o.value === sentence.solution
|
||||||
|
)}
|
||||||
|
options={selectOptions}
|
||||||
|
onChange={(value) => {
|
||||||
|
updateExercise({
|
||||||
|
sentences: exercise.sentences.map((iSol) =>
|
||||||
|
iSol.id === sentence.id
|
||||||
|
? {
|
||||||
|
...iSol,
|
||||||
|
solution: value?.value,
|
||||||
|
}
|
||||||
|
: iSol
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<h1>Options</h1>
|
||||||
|
<div className="w-full flex flex-wrap -mx-2">
|
||||||
|
{exercise.options.map((option, index) => (
|
||||||
|
<div key={option.id} className="flex flex-col w-full px-2">
|
||||||
|
<div className="flex w-full">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label={`Option ${index + 1}`}
|
||||||
|
name="option"
|
||||||
|
required
|
||||||
|
value={option.sentence}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
options: exercise.options.map((iSol) =>
|
||||||
|
iSol.id === option.id
|
||||||
|
? {
|
||||||
|
...iSol,
|
||||||
|
sentence: value,
|
||||||
|
}
|
||||||
|
: iSol
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="px-2"
|
||||||
|
/>
|
||||||
|
<div className="w-48 flex items-end px-2">
|
||||||
|
<Select
|
||||||
|
value={{
|
||||||
|
value: option.id,
|
||||||
|
label: option.id,
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: option.id,
|
||||||
|
label: option.id,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
disabled
|
||||||
|
onChange={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MatchSentencesEdit;
|
||||||
137
src/components/Generation/multiple.choice.edit.tsx
Normal file
137
src/components/Generation/multiple.choice.edit.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import {
|
||||||
|
MultipleChoiceExercise,
|
||||||
|
MultipleChoiceQuestion,
|
||||||
|
} from "@/interfaces/exam";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
exercise: MultipleChoiceExercise;
|
||||||
|
updateExercise: (data: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantOptions = [
|
||||||
|
{ value: "text", label: "Text", key: "text" },
|
||||||
|
{ value: "image", label: "Image", key: "src" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MultipleChoiceEdit = (props: Props) => {
|
||||||
|
const { exercise, updateExercise } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Questions</h1>
|
||||||
|
<div className="w-full flex-no-wrap -mx-2">
|
||||||
|
{exercise.questions.map((question: MultipleChoiceQuestion, index) => {
|
||||||
|
const variantValue = variantOptions.find(
|
||||||
|
(o) => o.value === question.variant
|
||||||
|
);
|
||||||
|
|
||||||
|
const solutionsOptions = question.options.map((option) => ({
|
||||||
|
value: option.id,
|
||||||
|
label: option.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const solutionValue = solutionsOptions.find(
|
||||||
|
(o) => o.value === question.solution
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div key={question.id} className="flex w-full px-2 flex-col">
|
||||||
|
<span>Question ID: {question.id}</span>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Prompt"
|
||||||
|
name="prompt"
|
||||||
|
required
|
||||||
|
value={question.prompt}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
questions: exercise.questions.map((sol) =>
|
||||||
|
sol.id === question.id ? { ...sol, prompt: value } : sol
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex w-full">
|
||||||
|
<div className="w-48 flex items-end px-2">
|
||||||
|
<Select
|
||||||
|
value={solutionValue}
|
||||||
|
options={solutionsOptions}
|
||||||
|
onChange={(value) => {
|
||||||
|
updateExercise({
|
||||||
|
questions: exercise.questions.map((sol) =>
|
||||||
|
sol.id === question.id
|
||||||
|
? { ...sol, solution: value?.value }
|
||||||
|
: sol
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-48 flex items-end px-2">
|
||||||
|
<Select
|
||||||
|
value={variantValue}
|
||||||
|
options={variantOptions}
|
||||||
|
onChange={(value) => {
|
||||||
|
updateExercise({
|
||||||
|
questions: exercise.questions.map((sol) =>
|
||||||
|
sol.id === question.id
|
||||||
|
? { ...sol, variant: value?.value }
|
||||||
|
: sol
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-wrap -mx-2">
|
||||||
|
{question.options.map((option) => (
|
||||||
|
<div
|
||||||
|
key={option.id}
|
||||||
|
className="flex sm:w-1/2 lg:w-1/4 px-2 px-2"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label={`Option ${option.id}`}
|
||||||
|
name="option"
|
||||||
|
required
|
||||||
|
value={option.text}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
questions: exercise.questions.map((sol) =>
|
||||||
|
sol.id === question.id
|
||||||
|
? {
|
||||||
|
...sol,
|
||||||
|
options: sol.options.map((opt) => {
|
||||||
|
if (
|
||||||
|
opt.id === option.id &&
|
||||||
|
variantValue?.key
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...opt,
|
||||||
|
[variantValue.key]: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return opt;
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: sol
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MultipleChoiceEdit;
|
||||||
7
src/components/Generation/speaking.edit.tsx
Normal file
7
src/components/Generation/speaking.edit.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const SpeakingEdit = () => {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SpeakingEdit;
|
||||||
71
src/components/Generation/true.false.edit.tsx
Normal file
71
src/components/Generation/true.false.edit.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { TrueFalseExercise } from "@/interfaces/exam";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
interface Props {
|
||||||
|
exercise: TrueFalseExercise;
|
||||||
|
updateExercise: (data: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ value: "true", label: "True" },
|
||||||
|
{ value: "false", label: "False" },
|
||||||
|
{ value: "not_given", label: "Not Given" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TrueFalseEdit = (props: Props) => {
|
||||||
|
const { exercise, updateExercise } = props;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Prompt"
|
||||||
|
name="prompt"
|
||||||
|
required
|
||||||
|
value={exercise.prompt}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
prompt: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<h1>Questions</h1>
|
||||||
|
<div className="w-full flex-no-wrap -mx-2">
|
||||||
|
{exercise.questions.map((question, index) => (
|
||||||
|
<div key={question.id} className="flex w-full px-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label={`Question ${index + 1}`}
|
||||||
|
name="question"
|
||||||
|
required
|
||||||
|
value={question.prompt}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
questions: exercise.questions.map((sol) =>
|
||||||
|
sol.id === question.id ? { ...sol, prompt: value } : sol
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="w-48 flex items-end px-2">
|
||||||
|
<Select
|
||||||
|
value={options.find((o) => o.value === question.solution)}
|
||||||
|
options={options}
|
||||||
|
onChange={(value) => {
|
||||||
|
updateExercise({
|
||||||
|
questions: exercise.questions.map((sol) =>
|
||||||
|
sol.id === question.id ? { ...sol, solution: value?.value } : sol
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-18"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrueFalseEdit;
|
||||||
94
src/components/Generation/write.blanks.edit.tsx
Normal file
94
src/components/Generation/write.blanks.edit.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import { WriteBlanksExercise } from "@/interfaces/exam";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
exercise: WriteBlanksExercise;
|
||||||
|
updateExercise: (data: any) => void;
|
||||||
|
}
|
||||||
|
const WriteBlankEdits = (props: Props) => {
|
||||||
|
const { exercise, updateExercise } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Prompt"
|
||||||
|
name="prompt"
|
||||||
|
required
|
||||||
|
value={exercise.prompt}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
prompt: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Text"
|
||||||
|
name="text"
|
||||||
|
required
|
||||||
|
value={exercise.text}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
text: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Max Words"
|
||||||
|
name="number"
|
||||||
|
required
|
||||||
|
value={exercise.maxWords}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
maxWords: Number(value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<h1>Solutions</h1>
|
||||||
|
<div className="w-full flex flex-wrap -mx-2">
|
||||||
|
{exercise.solutions.map((solution) => (
|
||||||
|
<div key={solution.id} className="flex flex-col w-full px-2">
|
||||||
|
<span>Solution ID: {solution.id}</span>
|
||||||
|
{/* TODO: Consider adding an add and delete button */}
|
||||||
|
<div className="flex flex-wrap">
|
||||||
|
{solution.solution.map((sol, solIndex) => (
|
||||||
|
<Input
|
||||||
|
key={`${sol}-${solIndex}`}
|
||||||
|
type="text"
|
||||||
|
label={`Solution ${solIndex + 1}`}
|
||||||
|
name="solution"
|
||||||
|
required
|
||||||
|
value={sol}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
solutions: exercise.solutions.map((iSol) =>
|
||||||
|
iSol.id === solution.id
|
||||||
|
? {
|
||||||
|
...iSol,
|
||||||
|
solution: iSol.solution.map((iiSol, iiIndex) => {
|
||||||
|
if (iiIndex === solIndex) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return iiSol;
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: iSol
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="sm:w-1/2 lg:w-1/4 px-2"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WriteBlankEdits;
|
||||||
7
src/components/Generation/writing.edit.tsx
Normal file
7
src/components/Generation/writing.edit.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const WritingEdit = () => {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WritingEdit;
|
||||||
@@ -11,14 +11,15 @@ interface Props {
|
|||||||
className?: string;
|
className?: string;
|
||||||
navDisabled?: boolean;
|
navDisabled?: boolean;
|
||||||
focusMode?: boolean;
|
focusMode?: boolean;
|
||||||
|
bgColor?: string;
|
||||||
onFocusLayerMouseEnter?: () => void;
|
onFocusLayerMouseEnter?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layout({user, children, className, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
export default function Layout({user, children, className, bgColor="bg-white", navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative">
|
<main className={clsx("w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative")}>
|
||||||
<Navbar
|
<Navbar
|
||||||
path={router.pathname}
|
path={router.pathname}
|
||||||
user={user}
|
user={user}
|
||||||
@@ -33,12 +34,12 @@ export default function Layout({user, children, className, navDisabled = false,
|
|||||||
focusMode={focusMode}
|
focusMode={focusMode}
|
||||||
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
|
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
|
||||||
className="-md:hidden"
|
className="-md:hidden"
|
||||||
userType={user.type}
|
user={user}
|
||||||
userId={user.id}
|
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-full min-h-full h-fit md:mr-8 bg-white shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2",
|
`w-full min-h-full md:mr-8 ${bgColor} shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2`,
|
||||||
|
bgColor !== "bg-white" ? "justify-center" : "h-fit",
|
||||||
className,
|
className,
|
||||||
)}>
|
)}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,254 +1,179 @@
|
|||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {
|
import {Ticket, TicketStatus, TicketStatusLabel, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
|
||||||
Ticket,
|
import {User} from "@/interfaces/user";
|
||||||
TicketStatus,
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
TicketStatusLabel,
|
|
||||||
TicketType,
|
|
||||||
TicketTypeLabel,
|
|
||||||
} from "@/interfaces/ticket";
|
|
||||||
import { User } from "@/interfaces/user";
|
|
||||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { useState } from "react";
|
import {useState} from "react";
|
||||||
import { toast } from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import Input from "../Low/Input";
|
import Input from "../Low/Input";
|
||||||
import Select from "../Low/Select";
|
import Select from "../Low/Select";
|
||||||
|
import {checkAccess} from "@/utils/permissions";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
ticket: Ticket;
|
ticket: Ticket;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TicketDisplay({ user, ticket, onClose }: Props) {
|
export default function TicketDisplay({user, ticket, onClose}: Props) {
|
||||||
const [subject] = useState(ticket.subject);
|
const [subject] = useState(ticket.subject);
|
||||||
const [type, setType] = useState<TicketType>(ticket.type);
|
const [type, setType] = useState<TicketType>(ticket.type);
|
||||||
const [description] = useState(ticket.description);
|
const [description] = useState(ticket.description);
|
||||||
const [reporter] = useState(ticket.reporter);
|
const [reporter] = useState(ticket.reporter);
|
||||||
const [reportedFrom] = useState(ticket.reportedFrom);
|
const [reportedFrom] = useState(ticket.reportedFrom);
|
||||||
const [status, setStatus] = useState(ticket.status);
|
const [status, setStatus] = useState(ticket.status);
|
||||||
const [assignedTo, setAssignedTo] = useState<string | null>(
|
const [assignedTo, setAssignedTo] = useState<string | null>(ticket.assignedTo || null);
|
||||||
ticket.assignedTo || null,
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const { users } = useUsers();
|
const {users} = useUsers();
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
if (!type)
|
if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"});
|
||||||
return toast.error("Please choose a type!", { toastId: "missing-type" });
|
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.patch(`/api/tickets/${ticket.id}`, {
|
.patch(`/api/tickets/${ticket.id}`, {
|
||||||
subject,
|
subject,
|
||||||
type,
|
type,
|
||||||
description,
|
description,
|
||||||
reporter,
|
reporter,
|
||||||
reportedFrom,
|
reportedFrom,
|
||||||
status,
|
status,
|
||||||
assignedTo,
|
assignedTo,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`The ticket has been updated!`, { toastId: "submitted" });
|
toast.success(`The ticket has been updated!`, {toastId: "submitted"});
|
||||||
onClose();
|
onClose();
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
toast.error("Something went wrong, please try again later!", {
|
toast.error("Something went wrong, please try again later!", {
|
||||||
toastId: "error",
|
toastId: "error",
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const del = () => {
|
const del = () => {
|
||||||
if (!confirm("Are you sure you want to delete this ticket?")) return;
|
if (!confirm("Are you sure you want to delete this ticket?")) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.delete(`/api/tickets/${ticket.id}`)
|
.delete(`/api/tickets/${ticket.id}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`The ticket has been deleted!`, { toastId: "submitted" });
|
toast.success(`The ticket has been deleted!`, {toastId: "submitted"});
|
||||||
onClose();
|
onClose();
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
toast.error("Something went wrong, please try again later!", {
|
toast.error("Something went wrong, please try again later!", {
|
||||||
toastId: "error",
|
toastId: "error",
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="flex flex-col gap-4 pt-8">
|
<form className="flex flex-col gap-4 pt-8">
|
||||||
<Input
|
<Input label="Subject" type="text" name="subject" placeholder="Subject..." value={subject} onChange={(e) => null} disabled />
|
||||||
label="Subject"
|
|
||||||
type="text"
|
|
||||||
name="subject"
|
|
||||||
placeholder="Subject..."
|
|
||||||
value={subject}
|
|
||||||
onChange={(e) => null}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex w-full flex-col gap-3">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">
|
<label className="text-mti-gray-dim text-base font-normal">Status</label>
|
||||||
Status
|
<Select
|
||||||
</label>
|
options={Object.keys(TicketStatusLabel).map((x) => ({
|
||||||
<Select
|
value: x,
|
||||||
options={Object.keys(TicketStatusLabel).map((x) => ({
|
label: TicketStatusLabel[x as keyof typeof TicketStatusLabel],
|
||||||
value: x,
|
}))}
|
||||||
label: TicketStatusLabel[x as keyof typeof TicketStatusLabel],
|
value={{value: status, label: TicketStatusLabel[status]}}
|
||||||
}))}
|
onChange={(value) => setStatus((value?.value as TicketStatus) ?? undefined)}
|
||||||
value={{ value: status, label: TicketStatusLabel[status] }}
|
placeholder="Status..."
|
||||||
onChange={(value) =>
|
/>
|
||||||
setStatus((value?.value as TicketStatus) ?? undefined)
|
</div>
|
||||||
}
|
<div className="flex w-full flex-col gap-3">
|
||||||
placeholder="Status..."
|
<label className="text-mti-gray-dim text-base font-normal">Type</label>
|
||||||
/>
|
<Select
|
||||||
</div>
|
options={Object.keys(TicketTypeLabel).map((x) => ({
|
||||||
<div className="flex w-full flex-col gap-3">
|
value: x,
|
||||||
<label className="text-mti-gray-dim text-base font-normal">
|
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
|
||||||
Type
|
}))}
|
||||||
</label>
|
value={{value: type, label: TicketTypeLabel[type]}}
|
||||||
<Select
|
onChange={(value) => setType(value!.value as TicketType)}
|
||||||
options={Object.keys(TicketTypeLabel).map((x) => ({
|
placeholder="Type..."
|
||||||
value: x,
|
/>
|
||||||
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
|
</div>
|
||||||
}))}
|
</div>
|
||||||
value={{ value: type, label: TicketTypeLabel[type] }}
|
|
||||||
onChange={(value) => setType(value!.value as TicketType)}
|
|
||||||
placeholder="Type..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex w-full flex-col gap-3">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">
|
<label className="text-mti-gray-dim text-base font-normal">Assignee</label>
|
||||||
Assignee
|
<Select
|
||||||
</label>
|
options={[
|
||||||
<Select
|
{value: "me", label: "Assign to me"},
|
||||||
options={[
|
...users
|
||||||
{ value: "me", label: "Assign to me" },
|
.filter((x) => checkAccess(x, ["admin", "developer", "agent"]))
|
||||||
...users
|
.map((u) => ({
|
||||||
.filter((x) => ["admin", "developer", "agent"].includes(x.type))
|
value: u.id,
|
||||||
.map((u) => ({
|
label: `${u.name} - ${u.email}`,
|
||||||
value: u.id,
|
})),
|
||||||
label: `${u.name} - ${u.email}`,
|
]}
|
||||||
})),
|
disabled={checkAccess(user, ["agent"])}
|
||||||
]}
|
value={
|
||||||
disabled={user.type === "agent"}
|
assignedTo
|
||||||
value={
|
? {
|
||||||
assignedTo
|
value: assignedTo,
|
||||||
? {
|
label: `${users.find((u) => u.id === assignedTo)?.name} - ${users.find((u) => u.id === assignedTo)?.email}`,
|
||||||
value: assignedTo,
|
}
|
||||||
label: `${users.find((u) => u.id === assignedTo)?.name} - ${users.find((u) => u.id === assignedTo)?.email}`,
|
: null
|
||||||
}
|
}
|
||||||
: null
|
onChange={(value) => (value ? setAssignedTo(value.value === "me" ? user.id : value.value) : setAssignedTo(null))}
|
||||||
}
|
placeholder="Assignee..."
|
||||||
onChange={(value) =>
|
isClearable
|
||||||
value
|
/>
|
||||||
? setAssignedTo(value.value === "me" ? user.id : value.value)
|
</div>
|
||||||
: setAssignedTo(null)
|
|
||||||
}
|
|
||||||
placeholder="Assignee..."
|
|
||||||
isClearable
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||||
<Input
|
<Input label="Reported From" type="text" name="reportedFrom" onChange={() => null} value={reportedFrom} disabled />
|
||||||
label="Reported From"
|
<Input label="Date" type="text" name="date" onChange={() => null} value={moment(ticket.date).format("DD/MM/YYYY - HH:mm")} disabled />
|
||||||
type="text"
|
</div>
|
||||||
name="reportedFrom"
|
|
||||||
onChange={() => null}
|
|
||||||
value={reportedFrom}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Date"
|
|
||||||
type="text"
|
|
||||||
name="date"
|
|
||||||
onChange={() => null}
|
|
||||||
value={moment(ticket.date).format("DD/MM/YYYY - HH:mm")}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||||
<Input
|
<Input label="Reporter's Name" type="text" name="reporter" onChange={() => null} value={reporter.name} disabled />
|
||||||
label="Reporter's Name"
|
<Input label="Reporter's E-mail" type="text" name="reporter" onChange={() => null} value={reporter.email} disabled />
|
||||||
type="text"
|
<Input
|
||||||
name="reporter"
|
label="Reporter's Type"
|
||||||
onChange={() => null}
|
type="text"
|
||||||
value={reporter.name}
|
name="reporterType"
|
||||||
disabled
|
onChange={() => null}
|
||||||
/>
|
value={USER_TYPE_LABELS[reporter.type]}
|
||||||
<Input
|
disabled
|
||||||
label="Reporter's E-mail"
|
/>
|
||||||
type="text"
|
</div>
|
||||||
name="reporter"
|
|
||||||
onChange={() => null}
|
|
||||||
value={reporter.email}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Reporter's Type"
|
|
||||||
type="text"
|
|
||||||
name="reporterType"
|
|
||||||
onChange={() => null}
|
|
||||||
value={USER_TYPE_LABELS[reporter.type]}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
|
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
|
||||||
placeholder="Write your ticket's description here..."
|
placeholder="Write your ticket's description here..."
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
value={description}
|
value={description}
|
||||||
spellCheck
|
spellCheck
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="-md:flex-col-reverse mt-2 flex w-full items-center justify-between gap-4">
|
<div className="-md:flex-col-reverse mt-2 flex w-full items-center justify-between gap-4">
|
||||||
<Button
|
<Button type="button" color="red" className="w-full md:max-w-[200px]" variant="outline" onClick={del} isLoading={isLoading}>
|
||||||
type="button"
|
Delete
|
||||||
color="red"
|
</Button>
|
||||||
className="w-full md:max-w-[200px]"
|
|
||||||
variant="outline"
|
|
||||||
onClick={del}
|
|
||||||
isLoading={isLoading}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="-md:flex-col-reverse flex w-full items-center justify-end gap-4">
|
<div className="-md:flex-col-reverse flex w-full items-center justify-end gap-4">
|
||||||
<Button
|
<Button type="button" color="red" className="w-full md:max-w-[200px]" variant="outline" onClick={onClose} isLoading={isLoading}>
|
||||||
type="button"
|
Cancel
|
||||||
color="red"
|
</Button>
|
||||||
className="w-full md:max-w-[200px]"
|
<Button type="button" className="w-full md:max-w-[200px]" isLoading={isLoading} onClick={submit}>
|
||||||
variant="outline"
|
Update
|
||||||
onClick={onClose}
|
</Button>
|
||||||
isLoading={isLoading}
|
</div>
|
||||||
>
|
</div>
|
||||||
Cancel
|
</form>
|
||||||
</Button>
|
);
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="w-full md:max-w-[200px]"
|
|
||||||
isLoading={isLoading}
|
|
||||||
onClick={submit}
|
|
||||||
>
|
|
||||||
Update
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {Ticket, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
|
import {Ticket, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
|
||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
@@ -20,6 +21,8 @@ export default function TicketSubmission({user, page, onClose}: Props) {
|
|||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const examState = useExamStore((state) => state);
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"});
|
if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"});
|
||||||
if (subject.trim() === "")
|
if (subject.trim() === "")
|
||||||
@@ -48,6 +51,18 @@ export default function TicketSubmission({user, page, onClose}: Props) {
|
|||||||
type,
|
type,
|
||||||
reportedFrom: page,
|
reportedFrom: page,
|
||||||
description,
|
description,
|
||||||
|
examInformation:
|
||||||
|
page.includes("exam") || page.includes("exercises")
|
||||||
|
? {
|
||||||
|
exam: examState.exam?.id || "",
|
||||||
|
exams: examState.exams.map((x) => x.id),
|
||||||
|
exerciseIndex: examState.exerciseIndex,
|
||||||
|
moduleIndex: examState.moduleIndex,
|
||||||
|
partIndex: examState.partIndex,
|
||||||
|
questionIndex: examState.questionIndex,
|
||||||
|
selectedModules: examState.selectedModules,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
axios
|
axios
|
||||||
|
|||||||
39
src/components/HighlightContent.tsx
Normal file
39
src/components/HighlightContent.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
const HighlightContent: React.FC<{
|
||||||
|
html: string;
|
||||||
|
highlightPhrases: string[],
|
||||||
|
firstOccurence?: boolean
|
||||||
|
}> = ({
|
||||||
|
html,
|
||||||
|
highlightPhrases,
|
||||||
|
firstOccurence = false
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
const createHighlightedContent = useCallback(() => {
|
||||||
|
if (highlightPhrases.length === 0) {
|
||||||
|
return { __html: html };
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeRegExp = (string: string) => {
|
||||||
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
};
|
||||||
|
|
||||||
|
const regex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'i');
|
||||||
|
const globalRegex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'gi');
|
||||||
|
|
||||||
|
let highlightedHtml = html;
|
||||||
|
|
||||||
|
if (firstOccurence) {
|
||||||
|
highlightedHtml = html.replace(regex, (match) => `<span style="background-color: yellow;">${match}</span>`);
|
||||||
|
} else {
|
||||||
|
highlightedHtml = html.replace(globalRegex, (match) => `<span style="background-color: yellow;">${match}</span>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { __html: highlightedHtml };
|
||||||
|
}, [html, highlightPhrases, firstOccurence]);
|
||||||
|
|
||||||
|
return <div dangerouslySetInnerHTML={createHighlightedContent()} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HighlightContent;
|
||||||
168
src/components/InfiniteCarousel.tsx
Normal file
168
src/components/InfiniteCarousel.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import React, { useRef, useEffect, useState, useCallback, ReactNode } from 'react';
|
||||||
|
import { useSpring, animated } from '@react-spring/web';
|
||||||
|
import { useDrag } from '@use-gesture/react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
interface InfiniteCarouselProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
height: string;
|
||||||
|
speed?: number;
|
||||||
|
gap?: number;
|
||||||
|
overlay?: ReactNode;
|
||||||
|
overlayFunc?: (index: number) => void;
|
||||||
|
overlayClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InfiniteCarousel: React.FC<InfiniteCarouselProps> = ({
|
||||||
|
children,
|
||||||
|
height,
|
||||||
|
speed = 20000,
|
||||||
|
gap = 16,
|
||||||
|
overlay = undefined,
|
||||||
|
overlayFunc = undefined,
|
||||||
|
overlayClassName = ""
|
||||||
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [containerWidth, setContainerWidth] = useState<number>(0);
|
||||||
|
const itemCount = React.Children.count(children);
|
||||||
|
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||||
|
const [itemWidth, setItemWidth] = useState<number>(0);
|
||||||
|
const [isInfinite, setIsInfinite] = useState<boolean>(true);
|
||||||
|
const dragStartX = useRef<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const containerWidth = containerRef.current.clientWidth;
|
||||||
|
setContainerWidth(containerWidth);
|
||||||
|
|
||||||
|
const firstChild = containerRef.current.firstElementChild?.firstElementChild as HTMLElement;
|
||||||
|
if (firstChild) {
|
||||||
|
const childWidth = firstChild.offsetWidth;
|
||||||
|
setItemWidth(childWidth);
|
||||||
|
|
||||||
|
const totalContentWidth = (childWidth + gap) * itemCount - gap;
|
||||||
|
setIsInfinite(totalContentWidth > containerWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleResize();
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
}, [gap, itemCount]);
|
||||||
|
|
||||||
|
const totalWidth = (itemWidth + gap) * itemCount;
|
||||||
|
|
||||||
|
const [{ x }, api] = useSpring(() => ({
|
||||||
|
from: { x: 0 },
|
||||||
|
to: { x: -totalWidth },
|
||||||
|
config: { duration: speed },
|
||||||
|
loop: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const startAnimation = useCallback(() => {
|
||||||
|
if (isInfinite) {
|
||||||
|
api.start({
|
||||||
|
from: { x: x.get() },
|
||||||
|
to: { x: x.get() - totalWidth },
|
||||||
|
config: { duration: speed },
|
||||||
|
loop: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
api.stop();
|
||||||
|
api.start({ x: 0, immediate: true });
|
||||||
|
}
|
||||||
|
}, [api, x, totalWidth, speed, isInfinite]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerWidth > 0 && !isDragging) {
|
||||||
|
startAnimation();
|
||||||
|
}
|
||||||
|
}, [containerWidth, isDragging, startAnimation]);
|
||||||
|
|
||||||
|
const bind = useDrag(({ down, movement: [mx], first }) => {
|
||||||
|
if (!isInfinite) return;
|
||||||
|
if (first) {
|
||||||
|
setIsDragging(true);
|
||||||
|
api.stop();
|
||||||
|
dragStartX.current = x.get();
|
||||||
|
}
|
||||||
|
if (down) {
|
||||||
|
let newX = dragStartX.current + mx;
|
||||||
|
newX = ((newX % totalWidth) + totalWidth) % totalWidth;
|
||||||
|
if (newX > 0) newX -= totalWidth;
|
||||||
|
api.start({ x: newX, immediate: true });
|
||||||
|
} else {
|
||||||
|
setIsDragging(false);
|
||||||
|
startAnimation();
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
filterTaps: true,
|
||||||
|
from: () => [x.get(), 0],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="overflow-hidden relative select-none"
|
||||||
|
style={{ height, touchAction: 'pan-y' }}
|
||||||
|
ref={containerRef}
|
||||||
|
{...(isInfinite ? bind() : {})}
|
||||||
|
>
|
||||||
|
<animated.div
|
||||||
|
className="flex"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
willChange: 'transform',
|
||||||
|
transform: isInfinite
|
||||||
|
? x.to((x) => `translate3d(${x}px, 0, 0)`)
|
||||||
|
: 'none',
|
||||||
|
gap: `${gap}px`,
|
||||||
|
width: 'fit-content',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{React.Children.map(children, (child, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex-shrink-0 relative"
|
||||||
|
>
|
||||||
|
{overlay !== undefined && overlayFunc !== undefined && (
|
||||||
|
<div className={clsx('absolute', overlayClassName)} onClick={() => overlayFunc(i)}>
|
||||||
|
{overlay}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="select-none"
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
{child}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isInfinite && React.Children.map(children, (child, i) => (
|
||||||
|
<div
|
||||||
|
key={`clone-${i}`}
|
||||||
|
className="flex-shrink-0 relative"
|
||||||
|
>
|
||||||
|
{overlay !== undefined && overlayFunc !== undefined && (
|
||||||
|
<div className={clsx('absolute', overlayClassName)} onClick={() => overlayFunc(i)}>
|
||||||
|
{overlay}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="select-none"
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
{child}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</animated.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InfiniteCarousel;
|
||||||
51
src/components/List.tsx
Normal file
51
src/components/List.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import {Column, flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table";
|
||||||
|
|
||||||
|
export default function List<T>({data, columns}: {data: T[]; columns: any[]}) {
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns: columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||||
|
<thead>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<th key={header.id} colSpan={header.colSpan}>
|
||||||
|
{header.isPlaceholder ? null : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
{...{
|
||||||
|
className: header.column.getCanSort() ? "cursor-pointer select-none py-4 text-left first:pl-4" : "",
|
||||||
|
onClick: header.column.getToggleSortingHandler(),
|
||||||
|
}}>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{{
|
||||||
|
asc: " 🔼",
|
||||||
|
desc: " 🔽",
|
||||||
|
}[header.column.getIsSorted() as string] ?? null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody className="px-2">
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td className="px-4 py-2" key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ interface Props {
|
|||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
padding?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
type?: "button" | "reset" | "submit";
|
type?: "button" | "reset" | "submit";
|
||||||
}
|
}
|
||||||
@@ -21,6 +22,7 @@ export default function Button({
|
|||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
type,
|
type,
|
||||||
|
padding = "py-4 px-6",
|
||||||
onClick,
|
onClick,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const colorClassNames: {[key in typeof color]: {[key in typeof variant]: string}} = {
|
const colorClassNames: {[key in typeof color]: {[key in typeof variant]: string}} = {
|
||||||
@@ -61,9 +63,10 @@ export default function Button({
|
|||||||
type={type}
|
type={type}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"py-4 px-6 rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed cursor-pointer",
|
"rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed cursor-pointer select-none",
|
||||||
className,
|
padding,
|
||||||
colorClassNames[color][variant],
|
colorClassNames[color][variant],
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
disabled={disabled || isLoading}>
|
disabled={disabled || isLoading}>
|
||||||
{!isLoading && children}
|
{!isLoading && children}
|
||||||
|
|||||||
@@ -11,14 +11,16 @@ interface Props {
|
|||||||
|
|
||||||
export default function Checkbox({isChecked, onChange, children, disabled}: Props) {
|
export default function Checkbox({isChecked, onChange, children, disabled}: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer" onClick={() => {
|
<div
|
||||||
if(disabled) return;
|
className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer"
|
||||||
onChange(!isChecked);
|
onClick={() => {
|
||||||
}}>
|
if (disabled) return;
|
||||||
|
onChange(!isChecked);
|
||||||
|
}}>
|
||||||
<input type="checkbox" className="hidden" />
|
<input type="checkbox" className="hidden" />
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
|
"w-6 h-6 min-w-6 min-h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
isChecked && "!bg-mti-purple-light ",
|
isChecked && "!bg-mti-purple-light ",
|
||||||
)}>
|
)}>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useState} from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
type: "email" | "text" | "password" | "tel" | "number";
|
type: "email" | "text" | "password" | "tel" | "number" | "textarea";
|
||||||
roundness?: "full" | "xl";
|
roundness?: "full" | "xl";
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -11,6 +11,7 @@ interface Props {
|
|||||||
value?: string | number;
|
value?: string | number;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
max?: number;
|
||||||
name: string;
|
name: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
@@ -23,6 +24,7 @@ export default function Input({
|
|||||||
required = false,
|
required = false,
|
||||||
value,
|
value,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
|
max,
|
||||||
className,
|
className,
|
||||||
roundness = "full",
|
roundness = "full",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -30,6 +32,20 @@ export default function Input({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
if (type === "textarea") {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
className="w-full h-full cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl min-h-[200px]"
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (type === "password") {
|
if (type === "password") {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
@@ -72,6 +88,7 @@ export default function Input({
|
|||||||
name={name}
|
name={name}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
value={value}
|
value={value}
|
||||||
|
max={max}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
min={type === "number" ? 0 : undefined}
|
min={type === "number" ? 0 : undefined}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {ComponentProps, useEffect, useState} from "react";
|
import {ComponentProps, useEffect, useState} from "react";
|
||||||
import ReactSelect from "react-select";
|
import ReactSelect, {GroupBase, StylesConfig} from "react-select";
|
||||||
|
|
||||||
interface Option {
|
interface Option {
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
value: string;
|
value: string | null;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,9 +16,11 @@ interface Props {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onChange: (value: Option | null) => void;
|
onChange: (value: Option | null) => void;
|
||||||
isClearable?: boolean;
|
isClearable?: boolean;
|
||||||
|
styles?: StylesConfig<Option, boolean, GroupBase<Option>>;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Select({value, defaultValue, options, placeholder, disabled, onChange, isClearable}: Props) {
|
export default function Select({value, defaultValue, options, placeholder, disabled, onChange, styles, isClearable, className}: Props) {
|
||||||
const [target, setTarget] = useState<HTMLElement>();
|
const [target, setTarget] = useState<HTMLElement>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -27,33 +29,40 @@ export default function Select({value, defaultValue, options, placeholder, disab
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactSelect
|
<ReactSelect
|
||||||
className={clsx(
|
className={
|
||||||
"placeholder:text-mti-gray-cool border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none",
|
styles
|
||||||
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
? undefined
|
||||||
)}
|
: clsx(
|
||||||
|
"placeholder:text-mti-gray-cool border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none",
|
||||||
|
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
||||||
|
className,
|
||||||
|
)
|
||||||
|
}
|
||||||
options={options}
|
options={options}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange as any}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
menuPortalTarget={target}
|
menuPortalTarget={target}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
styles={{
|
styles={
|
||||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
styles || {
|
||||||
control: (styles) => ({
|
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||||
...styles,
|
control: (styles) => ({
|
||||||
paddingLeft: "4px",
|
...styles,
|
||||||
border: "none",
|
paddingLeft: "4px",
|
||||||
outline: "none",
|
border: "none",
|
||||||
":focus": {
|
|
||||||
outline: "none",
|
outline: "none",
|
||||||
},
|
":focus": {
|
||||||
}),
|
outline: "none",
|
||||||
option: (styles, state) => ({
|
},
|
||||||
...styles,
|
}),
|
||||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
option: (styles, state) => ({
|
||||||
color: state.isFocused ? "black" : styles.color,
|
...styles,
|
||||||
}),
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
}}
|
color: state.isFocused ? "black" : styles.color,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
isDisabled={disabled}
|
isDisabled={disabled}
|
||||||
isClearable={isClearable}
|
isClearable={isClearable}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,51 +1,55 @@
|
|||||||
import {Module} from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import useExamStore from "@/stores/examStore";
|
import { moduleLabels } from "@/utils/moduleUtils";
|
||||||
import {moduleLabels} from "@/utils/moduleUtils";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {motion} from "framer-motion";
|
import { ReactNode, useState } from "react";
|
||||||
import {ReactNode, useEffect, useState} from "react";
|
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch } from "react-icons/bs";
|
||||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
|
|
||||||
import ProgressBar from "../Low/ProgressBar";
|
import ProgressBar from "../Low/ProgressBar";
|
||||||
import TimerEndedModal from "../TimerEndedModal";
|
import Timer from "./Timer";
|
||||||
|
import { Exercise, LevelExam, MultipleChoiceExercise, ShuffleMap, UserSolution } from "@/interfaces/exam";
|
||||||
|
import { BsFillGrid3X3GapFill } from "react-icons/bs";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
module: Module;
|
module: Module;
|
||||||
|
examLabel?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
exerciseIndex: number;
|
exerciseIndex: number;
|
||||||
totalExercises: number;
|
totalExercises: number;
|
||||||
disableTimer?: boolean;
|
disableTimer?: boolean;
|
||||||
|
partLabel?: string;
|
||||||
|
showTimer?: boolean;
|
||||||
|
showSolutions?: boolean;
|
||||||
|
currentExercise?: Exercise;
|
||||||
|
runOnClick?: ((questionIndex: number) => void) | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ModuleTitle({minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false}: Props) {
|
export default function ModuleTitle({
|
||||||
const [timer, setTimer] = useState(minTimer * 60);
|
minTimer,
|
||||||
const [showModal, setShowModal] = useState(false);
|
module,
|
||||||
const [warningMode, setWarningMode] = useState(false);
|
label,
|
||||||
|
examLabel,
|
||||||
|
exerciseIndex,
|
||||||
|
totalExercises,
|
||||||
|
disableTimer = false,
|
||||||
|
partLabel,
|
||||||
|
showTimer = true,
|
||||||
|
showSolutions = false,
|
||||||
|
runOnClick = undefined
|
||||||
|
}: Props) {
|
||||||
|
const {
|
||||||
|
userSolutions,
|
||||||
|
partIndex,
|
||||||
|
exam
|
||||||
|
} = useExamStore((state) => state);
|
||||||
|
const examExerciseIndex = useExamStore((state) => state.exerciseIndex)
|
||||||
|
|
||||||
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const {timeSpent} = useExamStore((state) => state);
|
|
||||||
|
|
||||||
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
|
const moduleIcon: { [key in Module]: ReactNode } = {
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!disableTimer) {
|
|
||||||
const timerInterval = setInterval(() => setTimer((prev) => prev - 1), 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(timerInterval);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [disableTimer, minTimer]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (timer <= 0) setShowModal(true);
|
|
||||||
}, [timer]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (timer < 300 && !warningMode) setWarningMode(true);
|
|
||||||
}, [timer, warningMode]);
|
|
||||||
|
|
||||||
const moduleIcon: {[key in Module]: ReactNode} = {
|
|
||||||
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
|
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
|
||||||
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
|
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
|
||||||
writing: <BsPen className="text-ielts-writing w-6 h-6" />,
|
writing: <BsPen className="text-ielts-writing w-6 h-6" />,
|
||||||
@@ -53,51 +57,133 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
|
|||||||
level: <BsClipboard className="text-ielts-level w-6 h-6" />,
|
level: <BsClipboard className="text-ielts-level w-6 h-6" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isMultipleChoiceLevelExercise = () => {
|
||||||
|
if (exam?.module === 'level' && typeof partIndex === "number" && partIndex > -1) {
|
||||||
|
const currentExercise = (exam as LevelExam).parts[partIndex].exercises[examExerciseIndex];
|
||||||
|
return currentExercise && currentExercise.type === 'multipleChoice';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMCQuestionGrid = () => {
|
||||||
|
if (!isMultipleChoiceLevelExercise() && !userSolutions) return null;
|
||||||
|
|
||||||
|
const currentExercise = (exam as LevelExam).parts[partIndex!].exercises[examExerciseIndex] as MultipleChoiceExercise;
|
||||||
|
const userSolution = userSolutions!.find((x) => x.exercise.toString() == currentExercise.id.toString())!;
|
||||||
|
const answeredQuestions = new Set(userSolution.solutions.map(sol => sol.question.toString()));
|
||||||
|
const exerciseOffset = Number(currentExercise.questions[0].id);
|
||||||
|
const lastExercise = exerciseOffset + (currentExercise.questions.length - 1);
|
||||||
|
|
||||||
|
const getQuestionColor = (questionId: string, solution: string, userQuestionSolution: string | undefined) => {
|
||||||
|
const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => {
|
||||||
|
if (foundMap) return foundMap;
|
||||||
|
return userSolution.shuffleMaps?.find(map => map.questionID.toString() === questionId.toString()) || null;
|
||||||
|
}, null as ShuffleMap | null);
|
||||||
|
const newSolution = questionShuffleMap ? questionShuffleMap?.map[solution] : solution;
|
||||||
|
|
||||||
|
if (!userSolutions) return "";
|
||||||
|
|
||||||
|
if (!userQuestionSolution) {
|
||||||
|
return "!bg-mti-gray-davy !border--mti-gray-davy !text-mti-gray-davy !text-white hover:!bg-gray-700";
|
||||||
|
}
|
||||||
|
|
||||||
|
return userQuestionSolution === newSolution ?
|
||||||
|
"!bg-mti-purple-light !text-mti-purple-light !text-white hover:!bg-mti-purple-dark" :
|
||||||
|
"!bg-mti-rose-light !border-mti-rose-light !text-mti-rose-light !text-white hover:!bg-mti-rose-dark";
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3 className="text-xl font-semibold mb-4 text-center">{`Part ${partIndex + 1} (Questions ${exerciseOffset} - ${lastExercise})`}</h3>
|
||||||
|
<div className="grid grid-cols-5 gap-3 px-4 py-2">
|
||||||
|
{currentExercise.questions.map((_, index) => {
|
||||||
|
const questionNumber = exerciseOffset + index;
|
||||||
|
const isAnswered = answeredQuestions.has(questionNumber.toString());
|
||||||
|
const solution = currentExercise.questions.find((x) => x.id.toString() == questionNumber.toString())!.solution;
|
||||||
|
|
||||||
|
const userQuestionSolution = currentExercise.userSolutions?.find((x) => x.question.toString() == questionNumber.toString())?.option;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant={showSolutions ? "solid" : (isAnswered ? "solid" : "outline")}
|
||||||
|
key={index}
|
||||||
|
className={clsx(
|
||||||
|
"w-12 h-12 flex items-center justify-center rounded-lg text-sm font-bold transition-all duration-200 ease-in-out",
|
||||||
|
(showSolutions ?
|
||||||
|
getQuestionColor(questionNumber.toString(), solution, userQuestionSolution) :
|
||||||
|
(isAnswered ?
|
||||||
|
"bg-mti-purple-light border-mti-purple-light text-white hover:bg-mti-purple-dark hover:border-mti-purple-dark" :
|
||||||
|
"bg-white border-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
onClick={() => { if (typeof runOnClick !== "undefined") { runOnClick(index); } setIsOpen(false); }}
|
||||||
|
>
|
||||||
|
{questionNumber}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm text-gray-600 text-center">
|
||||||
|
Click a question number to jump to that question
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TimerEndedModal
|
{showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />}
|
||||||
isOpen={showModal}
|
<div className="w-full">
|
||||||
onClose={() => {
|
{partLabel && (
|
||||||
setHasExamEnded(true);
|
<div className="text-3xl space-y-4">
|
||||||
setShowModal(false);
|
{partLabel.split("\n\n").map((partInstructions, index) => {
|
||||||
}}
|
if (index === 0)
|
||||||
/>
|
return (
|
||||||
<motion.div
|
<p key={index} className="font-bold">
|
||||||
className={clsx(
|
{partInstructions}
|
||||||
"absolute top-4 right-6 bg-mti-gray-seasalt px-4 py-3 flex items-center gap-2 rounded-full text-mti-gray-davy",
|
</p>
|
||||||
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt",
|
);
|
||||||
|
else
|
||||||
|
return (
|
||||||
|
<div key={index} className="text-2xl font-semibold flex flex-col gap-2">
|
||||||
|
{partInstructions.split("\\n").map((line, lineIndex) => (
|
||||||
|
<span key={lineIndex} dangerouslySetInnerHTML={{__html: line.replace('that is not correct', 'that is <span class="font-bold"><u>not correct</u></span>')}}></span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
initial={{scale: warningMode && !disableTimer ? 0.8 : 1}}
|
<div className={clsx("flex gap-6 w-full h-fit items-center", partLabel ? "mt-10" : "mt-5")}>
|
||||||
animate={{scale: warningMode && !disableTimer ? 1.1 : 1}}
|
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
|
||||||
transition={{repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut"}}>
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<BsStopwatch className="w-6 h-6" />
|
<div className="w-full flex justify-between">
|
||||||
<span className="text-base font-semibold w-12">
|
<span className="text-base font-semibold">
|
||||||
{timer > 0 && (
|
{module === "level"
|
||||||
|
? (examLabel ? examLabel : "Placement Test")
|
||||||
|
: `${moduleLabels[module]} exam${label ? ` - ${label}` : ''}`
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold self-end">
|
||||||
|
Question {exerciseIndex}/{totalExercises}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
|
||||||
|
</div>
|
||||||
|
{isMultipleChoiceLevelExercise() && (
|
||||||
<>
|
<>
|
||||||
{Math.floor(timer / 60)
|
<Button variant="outline" onClick={() => setIsOpen(true)} padding="p-2" className="rounded-lg">
|
||||||
.toString(10)
|
<BsFillGrid3X3GapFill size={24} />
|
||||||
.padStart(2, "0")}
|
</Button>
|
||||||
:
|
<Modal
|
||||||
{Math.floor(timer % 60)
|
isOpen={isOpen}
|
||||||
.toString(10)
|
onClose={() => setIsOpen(false)}
|
||||||
.padStart(2, "0")}
|
className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white shadow-xl transition-all"
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
{renderMCQuestionGrid()}
|
||||||
|
</>
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{timer <= 0 && <>00:00</>}
|
|
||||||
</span>
|
|
||||||
</motion.div>
|
|
||||||
<div className="flex gap-6 w-full h-fit items-center mt-5">
|
|
||||||
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
|
|
||||||
<div className="flex flex-col gap-3 w-full">
|
|
||||||
<div className="w-full flex justify-between">
|
|
||||||
<span className="text-base font-semibold">
|
|
||||||
{moduleLabels[module]} exam {label && `- ${label}`}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold self-end">
|
|
||||||
Exercise {exerciseIndex}/{totalExercises}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
206
src/components/Medium/RecordFilter.tsx
Normal file
206
src/components/Medium/RecordFilter.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { User } from "@/interfaces/user";
|
||||||
|
import { checkAccess } from "@/utils/permissions";
|
||||||
|
import Select from "../Low/Select";
|
||||||
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
import useRecordStore from "@/stores/recordStore";
|
||||||
|
|
||||||
|
|
||||||
|
type TimeFilter = "months" | "weeks" | "days";
|
||||||
|
type Filter = TimeFilter | "assignments" | undefined;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
filterState: {
|
||||||
|
filter: Filter,
|
||||||
|
setFilter: React.Dispatch<React.SetStateAction<Filter>>
|
||||||
|
},
|
||||||
|
assignments?: boolean;
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultSelectableCorporate = {
|
||||||
|
value: "",
|
||||||
|
label: "All",
|
||||||
|
};
|
||||||
|
|
||||||
|
const RecordFilter: React.FC<Props> = ({
|
||||||
|
user,
|
||||||
|
filterState,
|
||||||
|
assignments = true,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
const { filter, setFilter } = filterState;
|
||||||
|
|
||||||
|
const [statsUserId, setStatsUserId] = useRecordStore((state) => [
|
||||||
|
state.selectedUser,
|
||||||
|
state.setSelectedUser
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { users } = useUsers();
|
||||||
|
const { groups: allGroups } = useGroups({});
|
||||||
|
const { groups } = useGroups({ admin: user?.id, userType: user?.type });
|
||||||
|
|
||||||
|
const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => {
|
||||||
|
setFilter((prev) => (prev === value ? undefined : value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectableCorporates = [
|
||||||
|
defaultSelectableCorporate,
|
||||||
|
...users
|
||||||
|
.filter((x) => groups.flatMap((g) => [g.admin, ...g.participants]).includes(x.id))
|
||||||
|
.filter((x) => x.type === "corporate")
|
||||||
|
.map((x) => ({
|
||||||
|
value: x.id,
|
||||||
|
label: `${x.name} - ${x.email}`,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
const [selectedCorporate, setSelectedCorporate] = useState<string>(defaultSelectableCorporate.value);
|
||||||
|
|
||||||
|
const getUsersList = (): User[] => {
|
||||||
|
if (selectedCorporate) {
|
||||||
|
const selectedCorporateGroups = allGroups.filter((x) => x.admin === selectedCorporate);
|
||||||
|
const selectedCorporateGroupsParticipants = selectedCorporateGroups.flatMap((x) => x.participants);
|
||||||
|
|
||||||
|
const userListWithUsers = selectedCorporateGroupsParticipants.map((x) => users.find((y) => y.id === x)) as User[];
|
||||||
|
return userListWithUsers.filter((x) => x);
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.type !== "mastercorporate" ? users : users.filter((x) => groups.flatMap((g) => [g.admin, ...g.participants]).includes(x.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const corporateFilteredUserList = getUsersList();
|
||||||
|
|
||||||
|
const getSelectedUser = () => {
|
||||||
|
if (selectedCorporate) {
|
||||||
|
const userInCorporate = corporateFilteredUserList.find((x) => x.id === statsUserId);
|
||||||
|
return userInCorporate || corporateFilteredUserList[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return users.find((x) => x.id === statsUserId) || user;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedUser = getSelectedUser();
|
||||||
|
const selectedUserSelectValue = selectedUser
|
||||||
|
? {
|
||||||
|
value: selectedUser.id,
|
||||||
|
label: `${selectedUser.name} - ${selectedUser.email}`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
value: "",
|
||||||
|
label: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center">
|
||||||
|
<div className="xl:w-3/4">
|
||||||
|
{checkAccess(user, ["developer", "admin", "mastercorporate"]) && !children && (
|
||||||
|
<>
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Corporate</label>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
options={selectableCorporates}
|
||||||
|
value={selectableCorporates.find((x) => x.value === selectedCorporate)}
|
||||||
|
onChange={(value) => setSelectedCorporate(value?.value || "")}
|
||||||
|
styles={{
|
||||||
|
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||||
|
option: (styles, state) => ({
|
||||||
|
...styles,
|
||||||
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
|
color: state.isFocused ? "black" : styles.color,
|
||||||
|
}),
|
||||||
|
}}></Select>
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">User</label>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
options={corporateFilteredUserList.map((x) => ({
|
||||||
|
value: x.id,
|
||||||
|
label: `${x.name} - ${x.email}`,
|
||||||
|
}))}
|
||||||
|
value={selectedUserSelectValue}
|
||||||
|
onChange={(value) => setStatsUserId(value?.value!)}
|
||||||
|
styles={{
|
||||||
|
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||||
|
option: (styles, state) => ({
|
||||||
|
...styles,
|
||||||
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
|
color: state.isFocused ? "black" : styles.color,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(user.type === "corporate" || user.type === "teacher") && groups.length > 0 && !children && (
|
||||||
|
<>
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">User</label>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
options={users
|
||||||
|
.filter((x) => groups.flatMap((y) => y.participants).includes(x.id))
|
||||||
|
.map((x) => ({
|
||||||
|
value: x.id,
|
||||||
|
label: `${x.name} - ${x.email}`,
|
||||||
|
}))}
|
||||||
|
value={selectedUserSelectValue}
|
||||||
|
onChange={(value) => setStatsUserId(value?.value!)}
|
||||||
|
styles={{
|
||||||
|
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||||
|
option: (styles, state) => ({
|
||||||
|
...styles,
|
||||||
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
|
color: state.isFocused ? "black" : styles.color,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 w-full justify-center xl:justify-end">
|
||||||
|
{assignments && (
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
filter === "assignments" && "!bg-mti-purple-light !text-white",
|
||||||
|
)}
|
||||||
|
onClick={() => toggleFilter("assignments")}>
|
||||||
|
Assignments
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
filter === "months" && "!bg-mti-purple-light !text-white",
|
||||||
|
)}
|
||||||
|
onClick={() => toggleFilter("months")}>
|
||||||
|
Last month
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
filter === "weeks" && "!bg-mti-purple-light !text-white",
|
||||||
|
)}
|
||||||
|
onClick={() => toggleFilter("weeks")}>
|
||||||
|
Last week
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
filter === "days" && "!bg-mti-purple-light !text-white",
|
||||||
|
)}
|
||||||
|
onClick={() => toggleFilter("days")}>
|
||||||
|
Last day
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RecordFilter;
|
||||||
@@ -40,61 +40,71 @@ export default function SessionCard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-mti-gray-anti-flash flex w-64 flex-col gap-3 rounded-xl border p-4 text-black">
|
<div className="border-mti-gray-anti-flash flex w-64 flex-col justify-between gap-3 rounded-xl border p-4 text-black">
|
||||||
<span className="flex gap-1">
|
<div className="flex flex-col gap-3">
|
||||||
<b>ID:</b>
|
<span className="flex gap-1">
|
||||||
{session.sessionId}
|
<b>ID:</b>
|
||||||
</span>
|
{session.sessionId}
|
||||||
<span className="flex gap-1">
|
</span>
|
||||||
<b>Date:</b>
|
<span className="flex gap-1">
|
||||||
{moment(session.date).format("DD/MM/YYYY - HH:mm")}
|
<b>Date:</b>
|
||||||
</span>
|
{moment(session.date).format("DD/MM/YYYY - HH:mm")}
|
||||||
<div className="flex w-full items-center justify-between">
|
</span>
|
||||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-center justify-center gap-2">
|
{session.assignment && (
|
||||||
{session.selectedModules.sort(sortByModuleName).map((module) => (
|
<span className="flex flex-col gap-0">
|
||||||
<div
|
<b>Assignment:</b>
|
||||||
key={module}
|
{session.assignment.name}
|
||||||
data-tip={capitalize(module)}
|
</span>
|
||||||
className={clsx(
|
)}
|
||||||
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
|
||||||
module === "reading" && "bg-ielts-reading",
|
|
||||||
module === "listening" && "bg-ielts-listening",
|
|
||||||
module === "writing" && "bg-ielts-writing",
|
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
|
||||||
module === "level" && "bg-ielts-level",
|
|
||||||
)}>
|
|
||||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
|
||||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
|
||||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
|
||||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 w-full">
|
<div className="flex flex-col gap-3">
|
||||||
<button
|
<div className="flex w-full items-center justify-between">
|
||||||
onClick={async () => await loadSession(session)}
|
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-center justify-center gap-2">
|
||||||
disabled={isLoading}
|
{session.selectedModules.sort(sortByModuleName).map((module) => (
|
||||||
className="bg-mti-green-ultralight w-full hover:bg-mti-green-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
<div
|
||||||
{!isLoading && "Resume"}
|
key={module}
|
||||||
{isLoading && (
|
data-tip={capitalize(module)}
|
||||||
<div className="flex items-center justify-center">
|
className={clsx(
|
||||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||||
</div>
|
module === "reading" && "bg-ielts-reading",
|
||||||
)}
|
module === "listening" && "bg-ielts-listening",
|
||||||
</button>
|
module === "writing" && "bg-ielts-writing",
|
||||||
<button
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
onClick={deleteSession}
|
module === "level" && "bg-ielts-level",
|
||||||
disabled={isLoading}
|
)}>
|
||||||
className="bg-mti-red-ultralight w-full hover:bg-mti-red-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
{!isLoading && "Delete"}
|
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||||
{isLoading && (
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
<div className="flex items-center justify-center">
|
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</button>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 w-full">
|
||||||
|
<button
|
||||||
|
onClick={async () => await loadSession(session)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="bg-mti-green-ultralight w-full hover:bg-mti-green-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
||||||
|
{!isLoading && "Resume"}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={deleteSession}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="bg-mti-red-ultralight w-full hover:bg-mti-red-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
||||||
|
{!isLoading && "Delete"}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
313
src/components/Medium/StatGridItem.tsx
Normal file
313
src/components/Medium/StatGridItem.tsx
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {BsClock, BsXCircle} from "react-icons/bs";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {Stat, User} from "@/interfaces/user";
|
||||||
|
import {Module, Step} from "@/interfaces";
|
||||||
|
import ai_usage from "@/utils/ai.detection";
|
||||||
|
import {calculateBandScore} from "@/utils/score";
|
||||||
|
import moment from "moment";
|
||||||
|
import {Assignment} from "@/interfaces/results";
|
||||||
|
import {uuidv4} from "@firebase/util";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import {uniqBy} from "lodash";
|
||||||
|
import {sortByModule} from "@/utils/moduleUtils";
|
||||||
|
import {convertToUserSolutions} from "@/utils/stats";
|
||||||
|
import {getExamById} from "@/utils/exams";
|
||||||
|
import {Exam, UserSolution} from "@/interfaces/exam";
|
||||||
|
import ModuleBadge from "../ModuleBadge";
|
||||||
|
|
||||||
|
const formatTimestamp = (timestamp: string | number) => {
|
||||||
|
const time = typeof timestamp === "string" ? parseInt(timestamp) : timestamp;
|
||||||
|
const date = moment(time);
|
||||||
|
const formatter = "YYYY/MM/DD - HH:mm";
|
||||||
|
return date.format(formatter);
|
||||||
|
};
|
||||||
|
|
||||||
|
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
||||||
|
const scores: {
|
||||||
|
[key in Module]: {total: number; missing: number; correct: number};
|
||||||
|
} = {
|
||||||
|
reading: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
listening: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
writing: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
speaking: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
level: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
stats.forEach((x) => {
|
||||||
|
scores[x.module!] = {
|
||||||
|
total: scores[x.module!].total + x.score.total,
|
||||||
|
correct: scores[x.module!].correct + x.score.correct,
|
||||||
|
missing: scores[x.module!].missing + x.score.missing,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.keys(scores)
|
||||||
|
.filter((x) => scores[x as Module].total > 0)
|
||||||
|
.map((x) => ({module: x as Module, ...scores[x as Module]}));
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StatsGridItemProps {
|
||||||
|
width?: string | undefined;
|
||||||
|
height?: string | undefined;
|
||||||
|
examNumber?: number | undefined;
|
||||||
|
stats: Stat[];
|
||||||
|
timestamp: string | number;
|
||||||
|
user: User;
|
||||||
|
assignments: Assignment[];
|
||||||
|
users: User[];
|
||||||
|
training?: boolean;
|
||||||
|
gradingSystem?: Step[];
|
||||||
|
selectedTrainingExams?: string[];
|
||||||
|
maxTrainingExams?: number;
|
||||||
|
setSelectedTrainingExams?: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
|
setExams: (exams: Exam[]) => void;
|
||||||
|
setShowSolutions: (show: boolean) => void;
|
||||||
|
setUserSolutions: (solutions: UserSolution[]) => void;
|
||||||
|
setSelectedModules: (modules: Module[]) => void;
|
||||||
|
setInactivity: (inactivity: number) => void;
|
||||||
|
setTimeSpent: (time: number) => void;
|
||||||
|
renderPdfIcon: (session: string, color: string, textColor: string) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatsGridItem: React.FC<StatsGridItemProps> = ({
|
||||||
|
stats,
|
||||||
|
timestamp,
|
||||||
|
user,
|
||||||
|
assignments,
|
||||||
|
users,
|
||||||
|
training,
|
||||||
|
selectedTrainingExams,
|
||||||
|
gradingSystem,
|
||||||
|
setSelectedTrainingExams,
|
||||||
|
setExams,
|
||||||
|
setShowSolutions,
|
||||||
|
setUserSolutions,
|
||||||
|
setSelectedModules,
|
||||||
|
setInactivity,
|
||||||
|
setTimeSpent,
|
||||||
|
renderPdfIcon,
|
||||||
|
width = undefined,
|
||||||
|
height = undefined,
|
||||||
|
examNumber = undefined,
|
||||||
|
maxTrainingExams = undefined,
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
||||||
|
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
||||||
|
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
||||||
|
const assignmentID = stats.reduce((_, current) => current.assignment as any, "");
|
||||||
|
const assignment = assignments.find((a) => a.id === assignmentID);
|
||||||
|
const isDisabled = stats.some((x) => x.isDisabled);
|
||||||
|
|
||||||
|
const aiUsage = Math.round(ai_usage(stats) * 100);
|
||||||
|
|
||||||
|
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||||
|
module: x.module,
|
||||||
|
level: calculateBandScore(x.correct, x.total, x.module, user.focus),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const textColor = clsx(
|
||||||
|
correct / total >= 0.7 && "text-mti-purple",
|
||||||
|
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||||
|
correct / total < 0.3 && "text-mti-rose",
|
||||||
|
);
|
||||||
|
|
||||||
|
const {timeSpent, inactivity, session} = stats[0];
|
||||||
|
|
||||||
|
const selectExam = () => {
|
||||||
|
if (
|
||||||
|
training &&
|
||||||
|
!isDisabled &&
|
||||||
|
typeof maxTrainingExams !== "undefined" &&
|
||||||
|
typeof setSelectedTrainingExams !== "undefined" &&
|
||||||
|
typeof timestamp == "string"
|
||||||
|
) {
|
||||||
|
setSelectedTrainingExams((prevExams) => {
|
||||||
|
const uniqueExams = [...new Set(stats.map((stat) => `${stat.module}-${stat.date}`))];
|
||||||
|
const indexes = uniqueExams.map((exam) => prevExams.indexOf(exam)).filter((index) => index !== -1);
|
||||||
|
if (indexes.length > 0) {
|
||||||
|
const newExams = [...prevExams];
|
||||||
|
indexes
|
||||||
|
.sort((a, b) => b - a)
|
||||||
|
.forEach((index) => {
|
||||||
|
newExams.splice(index, 1);
|
||||||
|
});
|
||||||
|
return newExams;
|
||||||
|
} else {
|
||||||
|
if (prevExams.length + uniqueExams.length <= maxTrainingExams) {
|
||||||
|
return [...prevExams, ...uniqueExams];
|
||||||
|
} else {
|
||||||
|
return prevExams;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const examPromises = uniqBy(stats, "exam").map((stat) => {
|
||||||
|
return getExamById(stat.module, stat.exam);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDisabled) return;
|
||||||
|
|
||||||
|
Promise.all(examPromises).then((exams) => {
|
||||||
|
if (exams.every((x) => !!x)) {
|
||||||
|
if (!!timeSpent) setTimeSpent(timeSpent);
|
||||||
|
if (!!inactivity) setInactivity(inactivity);
|
||||||
|
setUserSolutions(convertToUserSolutions(stats));
|
||||||
|
setShowSolutions(true);
|
||||||
|
setExams(exams.map((x) => x!).sort(sortByModule));
|
||||||
|
setSelectedModules(
|
||||||
|
exams
|
||||||
|
.map((x) => x!)
|
||||||
|
.sort(sortByModule)
|
||||||
|
.map((x) => x!.module),
|
||||||
|
);
|
||||||
|
router.push("/exercises");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldRenderPDFIcon = () => {
|
||||||
|
if (assignment) {
|
||||||
|
return assignment.released;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
|
||||||
|
<div className="flex flex-col md:gap-1 -md:gap-2 2xl:gap-2">
|
||||||
|
<span className="font-medium">{formatTimestamp(timestamp)}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!!timeSpent && (
|
||||||
|
<span className="text-sm flex gap-2 items-center tooltip" data-tip="Time Spent">
|
||||||
|
<BsClock /> {Math.floor(timeSpent / 60)} minutes
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!!inactivity && (
|
||||||
|
<span className="text-sm flex gap-2 items-center tooltip" data-tip="Inactivity">
|
||||||
|
<BsXCircle /> {Math.floor(inactivity / 60)} minutes
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
{!!assignment && (assignment.released || assignment.released === undefined) && (
|
||||||
|
<span className={textColor}>
|
||||||
|
Level{" "}
|
||||||
|
{(
|
||||||
|
aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length
|
||||||
|
).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{shouldRenderPDFIcon() && renderPdfIcon(session, textColor, textColor)}
|
||||||
|
</div>
|
||||||
|
{examNumber === undefined ? (
|
||||||
|
<>
|
||||||
|
{aiUsage >= 50 && user.type !== "student" && (
|
||||||
|
<div
|
||||||
|
className={clsx("ml-auto border px-1 rounded w-fit mr-1", {
|
||||||
|
"bg-orange-100 border-orange-400 text-orange-700": aiUsage < 80,
|
||||||
|
"bg-red-100 border-red-400 text-red-700": aiUsage >= 80,
|
||||||
|
})}>
|
||||||
|
<span className="text-xs">AI Usage</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<span className="font-semibold bg-gray-200 text-gray-800 px-2.5 py-0.5 rounded-full mt-0.5">{examNumber}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full flex flex-col gap-1">
|
||||||
|
<div className={clsx("grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2", examNumber !== undefined && "pr-10")}>
|
||||||
|
{!!assignment &&
|
||||||
|
(assignment.released || assignment.released === undefined) &&
|
||||||
|
aggregatedLevels.map(({module, level}) => <ModuleBadge key={module} module={module} level={level} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{assignment && (
|
||||||
|
<span className="font-light text-sm">
|
||||||
|
Assignment: {assignment.name}, Teacher: {users.find((u) => u.id === assignment.assigner)?.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
key={uuidv4()}
|
||||||
|
className={clsx(
|
||||||
|
"flex flex-col justify-between gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden",
|
||||||
|
(isDisabled || (!!assignment && !assignment.released)) && "grayscale tooltip",
|
||||||
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
|
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||||
|
correct / total < 0.3 && "hover:border-mti-rose",
|
||||||
|
typeof selectedTrainingExams !== "undefined" &&
|
||||||
|
typeof timestamp === "string" &&
|
||||||
|
selectedTrainingExams.some((exam) => exam.includes(timestamp)) &&
|
||||||
|
"border-2 border-slate-600",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!!assignment && !assignment.released) return;
|
||||||
|
if (examNumber === undefined) return selectExam();
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
...(width !== undefined && {width}),
|
||||||
|
...(height !== undefined && {height}),
|
||||||
|
}}
|
||||||
|
data-tip={isDisabled ? "This exam is still being evaluated..." : "This exam is still locked by its assigner..."}
|
||||||
|
role="button">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
key={uuidv4()}
|
||||||
|
className={clsx(
|
||||||
|
"flex flex-col gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:tooltip md:hidden",
|
||||||
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
|
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||||
|
correct / total < 0.3 && "hover:border-mti-rose",
|
||||||
|
)}
|
||||||
|
data-tip="Your screen size is too small to view previous exams."
|
||||||
|
style={{
|
||||||
|
...(width !== undefined && {width}),
|
||||||
|
...(height !== undefined && {height}),
|
||||||
|
}}
|
||||||
|
role="button">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatsGridItem;
|
||||||
80
src/components/Medium/Timer.tsx
Normal file
80
src/components/Medium/Timer.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import {motion} from "framer-motion";
|
||||||
|
import TimerEndedModal from "../TimerEndedModal";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {BsStopwatch} from "react-icons/bs";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
minTimer: number;
|
||||||
|
disableTimer?: boolean;
|
||||||
|
standalone?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Timer: React.FC<Props> = ({minTimer, disableTimer, standalone = false}) => {
|
||||||
|
const [timer, setTimer] = useState(minTimer * 60);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [warningMode, setWarningMode] = useState(false);
|
||||||
|
|
||||||
|
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
||||||
|
const {timeSpent} = useExamStore((state) => state);
|
||||||
|
|
||||||
|
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!disableTimer) {
|
||||||
|
const timerInterval = setInterval(() => setTimer((prev) => prev - 1), 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(timerInterval);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [disableTimer, minTimer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timer <= 0) setShowModal(true);
|
||||||
|
}, [timer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timer < 300 && !warningMode) setWarningMode(true);
|
||||||
|
}, [timer, warningMode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TimerEndedModal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={() => {
|
||||||
|
setHasExamEnded(true);
|
||||||
|
setShowModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className={clsx(
|
||||||
|
"absolute right-6 bg-mti-gray-seasalt px-4 py-3 flex items-center gap-2 rounded-full text-mti-gray-davy",
|
||||||
|
standalone ? "top-10" : "top-4",
|
||||||
|
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt",
|
||||||
|
)}
|
||||||
|
initial={{scale: warningMode && !disableTimer ? 0.8 : 1}}
|
||||||
|
animate={{scale: warningMode && !disableTimer ? 1.1 : 1}}
|
||||||
|
transition={{repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut"}}>
|
||||||
|
<BsStopwatch className="w-6 h-6" />
|
||||||
|
<span className="text-lg font-bold w-12">
|
||||||
|
{timer > 0 && (
|
||||||
|
<>
|
||||||
|
{Math.floor(timer / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(timer % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{timer <= 0 && <>00:00</>}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Timer;
|
||||||
@@ -1,160 +1,215 @@
|
|||||||
import {User} from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import {Dialog, Transition} from "@headlessui/react";
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {Fragment} from "react";
|
import { Fragment } from "react";
|
||||||
import {BsXLg} from "react-icons/bs";
|
import { BsXLg } from "react-icons/bs";
|
||||||
|
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
path: string;
|
path: string;
|
||||||
user: User;
|
user: User;
|
||||||
disableNavigation?: boolean;
|
disableNavigation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MobileMenu({isOpen, onClose, path, user, disableNavigation}: Props) {
|
export default function MobileMenu({
|
||||||
const router = useRouter();
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
path,
|
||||||
|
user,
|
||||||
|
disableNavigation,
|
||||||
|
}: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
axios.post("/api/logout").finally(() => {
|
axios.post("/api/logout").finally(() => {
|
||||||
setTimeout(() => router.reload(), 500);
|
setTimeout(() => router.reload(), 500);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="ease-in duration-200"
|
leave="ease-in duration-200"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0">
|
leaveTo="opacity-0"
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
>
|
||||||
</Transition.Child>
|
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
<div className="flex min-h-full items-center justify-center text-center">
|
<div className="flex min-h-full items-center justify-center text-center">
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
enterFrom="opacity-0 scale-95"
|
enterFrom="opacity-0 scale-95"
|
||||||
enterTo="opacity-100 scale-100"
|
enterTo="opacity-100 scale-100"
|
||||||
leave="ease-in duration-200"
|
leave="ease-in duration-200"
|
||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95">
|
leaveTo="opacity-0 scale-95"
|
||||||
<Dialog.Panel className="flex h-screen w-full transform flex-col gap-8 overflow-hidden bg-white text-left align-middle text-black shadow-xl transition-all">
|
>
|
||||||
<Dialog.Title as="header" className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden">
|
<Dialog.Panel className="flex h-screen w-full transform flex-col gap-8 overflow-hidden bg-white text-left align-middle text-black shadow-xl transition-all">
|
||||||
<Link href={disableNavigation ? "" : "/"}>
|
<Dialog.Title
|
||||||
<Image src="/logo_title.png" alt="EnCoach logo" width={69} height={69} />
|
as="header"
|
||||||
</Link>
|
className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden"
|
||||||
<div className="cursor-pointer" onClick={onClose} tabIndex={0}>
|
>
|
||||||
<BsXLg className="text-mti-purple-light text-2xl" onClick={onClose} />
|
<Link href={disableNavigation ? "" : "/"}>
|
||||||
</div>
|
<Image
|
||||||
</Dialog.Title>
|
src="/logo_title.png"
|
||||||
<div className="flex h-full flex-col gap-6 px-8 text-lg">
|
alt="EnCoach logo"
|
||||||
<Link
|
width={69}
|
||||||
href={disableNavigation ? "" : "/"}
|
height={69}
|
||||||
className={clsx(
|
/>
|
||||||
"w-fit transition duration-300 ease-in-out",
|
</Link>
|
||||||
path === "/" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
<div
|
||||||
)}>
|
className="cursor-pointer"
|
||||||
Dashboard
|
onClick={onClose}
|
||||||
</Link>
|
tabIndex={0}
|
||||||
{(user.type === "student" || user.type === "teacher" || user.type === "developer") && (
|
>
|
||||||
<>
|
<BsXLg
|
||||||
<Link
|
className="text-mti-purple-light text-2xl"
|
||||||
href={disableNavigation ? "" : "/exam"}
|
onClick={onClose}
|
||||||
className={clsx(
|
/>
|
||||||
"w-fit transition duration-300 ease-in-out",
|
</div>
|
||||||
path === "/exam" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
</Dialog.Title>
|
||||||
)}>
|
<div className="flex h-full flex-col gap-6 px-8 text-lg">
|
||||||
Exams
|
<Link
|
||||||
</Link>
|
href={disableNavigation ? "" : "/"}
|
||||||
<Link
|
className={clsx(
|
||||||
href={disableNavigation ? "" : "/exercises"}
|
"w-fit transition duration-300 ease-in-out",
|
||||||
className={clsx(
|
path === "/" &&
|
||||||
"w-fit transition duration-300 ease-in-out",
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
path === "/exercises" &&
|
)}
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
>
|
||||||
)}>
|
Dashboard
|
||||||
Exercises
|
</Link>
|
||||||
</Link>
|
{checkAccess(user, ["student", "teacher", "developer"]) && (
|
||||||
</>
|
<>
|
||||||
)}
|
<Link
|
||||||
<Link
|
href={disableNavigation ? "" : "/exam"}
|
||||||
href={disableNavigation ? "" : "/stats"}
|
className={clsx(
|
||||||
className={clsx(
|
"w-fit transition duration-300 ease-in-out",
|
||||||
"w-fit transition duration-300 ease-in-out",
|
path === "/exam" &&
|
||||||
path === "/stats" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
)}>
|
)}
|
||||||
Stats
|
>
|
||||||
</Link>
|
Exams
|
||||||
<Link
|
</Link>
|
||||||
href={disableNavigation ? "" : "/record"}
|
<Link
|
||||||
className={clsx(
|
href={disableNavigation ? "" : "/exercises"}
|
||||||
"w-fit transition duration-300 ease-in-out",
|
className={clsx(
|
||||||
path === "/record" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
)}>
|
path === "/exercises" &&
|
||||||
Record
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
</Link>
|
)}
|
||||||
{["admin", "developer", "agent", "corporate"].includes(user.type) && (
|
>
|
||||||
<Link
|
Exercises
|
||||||
href={disableNavigation ? "" : "/payment-record"}
|
</Link>
|
||||||
className={clsx(
|
</>
|
||||||
"w-fit transition duration-300 ease-in-out",
|
)}
|
||||||
path === "/payment-record" &&
|
<Link
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
href={disableNavigation ? "" : "/stats"}
|
||||||
)}>
|
className={clsx(
|
||||||
Payment Record
|
"w-fit transition duration-300 ease-in-out",
|
||||||
</Link>
|
path === "/stats" &&
|
||||||
)}
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
{["admin", "developer", "corporate", "teacher"].includes(user.type) && (
|
)}
|
||||||
<Link
|
>
|
||||||
href={disableNavigation ? "" : "/settings"}
|
Stats
|
||||||
className={clsx(
|
</Link>
|
||||||
"w-fit transition duration-300 ease-in-out",
|
<Link
|
||||||
path === "/settings" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
href={disableNavigation ? "" : "/record"}
|
||||||
)}>
|
className={clsx(
|
||||||
Settings
|
"w-fit transition duration-300 ease-in-out",
|
||||||
</Link>
|
path === "/record" &&
|
||||||
)}
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
{["admin", "developer", "agent"].includes(user.type) && (
|
)}
|
||||||
<Link
|
>
|
||||||
href={disableNavigation ? "" : "/tickets"}
|
Record
|
||||||
className={clsx(
|
</Link>
|
||||||
"w-fit transition duration-300 ease-in-out",
|
{checkAccess(user, [
|
||||||
path === "/tickets" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
"admin",
|
||||||
)}>
|
"developer",
|
||||||
Tickets
|
"agent",
|
||||||
</Link>
|
"corporate",
|
||||||
)}
|
"mastercorporate",
|
||||||
<Link
|
]) && (
|
||||||
href={disableNavigation ? "" : "/profile"}
|
<Link
|
||||||
className={clsx(
|
href={disableNavigation ? "" : "/payment-record"}
|
||||||
"w-fit transition duration-300 ease-in-out",
|
className={clsx(
|
||||||
path === "/profile" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
)}>
|
path === "/payment-record" &&
|
||||||
Profile
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
</Link>
|
)}
|
||||||
|
>
|
||||||
|
Payment Record
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{checkAccess(user, [
|
||||||
|
"admin",
|
||||||
|
"developer",
|
||||||
|
"corporate",
|
||||||
|
"teacher",
|
||||||
|
"mastercorporate",
|
||||||
|
]) && (
|
||||||
|
<Link
|
||||||
|
href={disableNavigation ? "" : "/settings"}
|
||||||
|
className={clsx(
|
||||||
|
"w-fit transition duration-300 ease-in-out",
|
||||||
|
path === "/settings" &&
|
||||||
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{checkAccess(user, ["admin", "developer", "agent"]) && (
|
||||||
|
<Link
|
||||||
|
href={disableNavigation ? "" : "/tickets"}
|
||||||
|
className={clsx(
|
||||||
|
"w-fit transition duration-300 ease-in-out",
|
||||||
|
path === "/tickets" &&
|
||||||
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Tickets
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={disableNavigation ? "" : "/profile"}
|
||||||
|
className={clsx(
|
||||||
|
"w-fit transition duration-300 ease-in-out",
|
||||||
|
path === "/profile" &&
|
||||||
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Profile
|
||||||
|
</Link>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className={clsx("w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out")}
|
className={clsx(
|
||||||
onClick={logout}>
|
"w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out"
|
||||||
Logout
|
)}
|
||||||
</span>
|
onClick={logout}
|
||||||
</div>
|
>
|
||||||
</Dialog.Panel>
|
Logout
|
||||||
</Transition.Child>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Transition.Child>
|
||||||
</Transition>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import {Dialog, Transition} from "@headlessui/react";
|
import {Dialog, Transition} from "@headlessui/react";
|
||||||
|
import clsx from "clsx";
|
||||||
import {Fragment, ReactElement} from "react";
|
import {Fragment, ReactElement} from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
className?: string;
|
||||||
|
titleClassName?: string;
|
||||||
children?: ReactElement;
|
children?: ReactElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Modal({isOpen, title, onClose, children}: Props) {
|
export default function Modal({isOpen, title, className, titleClassName, onClose, children}: Props) {
|
||||||
return (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
<Dialog as="div" className="relative z-[200]" onClose={onClose}>
|
<Dialog as="div" className="relative z-[200]" onClose={onClose}>
|
||||||
@@ -33,9 +36,13 @@ export default function Modal({isOpen, title, onClose, children}: Props) {
|
|||||||
leave="ease-in duration-200"
|
leave="ease-in duration-200"
|
||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95">
|
leaveTo="opacity-0 scale-95">
|
||||||
<Dialog.Panel className="w-full max-w-6xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
|
<Dialog.Panel
|
||||||
|
className={clsx(
|
||||||
|
"w-full max-w-6xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all",
|
||||||
|
className,
|
||||||
|
)}>
|
||||||
{title && (
|
{title && (
|
||||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
<Dialog.Title as="h3" className={clsx(titleClassName ? titleClassName : "text-lg font-medium leading-6 text-gray-900")}>
|
||||||
{title}
|
{title}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
)}
|
)}
|
||||||
|
|||||||
34
src/components/ModuleBadge.tsx
Normal file
34
src/components/ModuleBadge.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import {Step} from "@/interfaces";
|
||||||
|
import {getGradingLabel, getLevelLabel} from "@/utils/score";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||||
|
|
||||||
|
const ModuleBadge: React.FC<{module: string; level?: number; gradingSystem?: Step[]; className?: string}> = ({
|
||||||
|
module,
|
||||||
|
level,
|
||||||
|
gradingSystem,
|
||||||
|
className,
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"flex gap-2 justify-center items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
|
||||||
|
module === "reading" && "bg-ielts-reading",
|
||||||
|
module === "listening" && "bg-ielts-listening",
|
||||||
|
module === "writing" && "bg-ielts-writing",
|
||||||
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
|
module === "level" && "bg-ielts-level",
|
||||||
|
className,
|
||||||
|
)}>
|
||||||
|
{module === "reading" && <BsBook className="w-4 h-4" />}
|
||||||
|
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
||||||
|
{module === "writing" && <BsPen className="w-4 h-4" />}
|
||||||
|
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
||||||
|
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
||||||
|
{/* do not switch to level && it will convert the 0.0 to 0*/}
|
||||||
|
{level !== undefined && (
|
||||||
|
<span className="text-sm">{module === "level" && gradingSystem ? getGradingLabel(level, gradingSystem) : level.toFixed(1)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ModuleBadge;
|
||||||
@@ -16,7 +16,10 @@ import Button from "./Low/Button";
|
|||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
import Input from "./Low/Input";
|
import Input from "./Low/Input";
|
||||||
import TicketSubmission from "./High/TicketSubmission";
|
import TicketSubmission from "./High/TicketSubmission";
|
||||||
|
import {Module} from "@/interfaces";
|
||||||
|
import Badge from "./Low/Badge";
|
||||||
|
|
||||||
|
import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
navDisabled?: boolean;
|
navDisabled?: boolean;
|
||||||
@@ -31,6 +34,8 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
|||||||
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
||||||
const [isTicketOpen, setIsTicketOpen] = useState(false);
|
const [isTicketOpen, setIsTicketOpen] = useState(false);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||||
|
|
||||||
const expirationDateColor = (date: Date) => {
|
const expirationDateColor = (date: Date) => {
|
||||||
@@ -56,10 +61,39 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
|||||||
isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result));
|
isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result));
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
const badges = [
|
||||||
|
{
|
||||||
|
module: "reading",
|
||||||
|
icon: () => <BsBook className="h-4 w-4 text-white" />,
|
||||||
|
achieved: user.levels.reading >= user.desiredLevels.reading,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
module: "listening",
|
||||||
|
icon: () => <BsHeadphones className="h-4 w-4 text-white" />,
|
||||||
|
achieved: user.levels.listening >= user.desiredLevels.listening,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "writing",
|
||||||
|
icon: () => <BsPen className="h-4 w-4 text-white" />,
|
||||||
|
achieved: user.levels.writing >= user.desiredLevels.writing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "speaking",
|
||||||
|
icon: () => <BsMegaphone className="h-4 w-4 text-white" />,
|
||||||
|
achieved: user.levels.speaking >= user.desiredLevels.speaking,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: "level",
|
||||||
|
icon: () => <BsClipboard className="h-4 w-4 text-white" />,
|
||||||
|
achieved: user.levels.level >= user.desiredLevels.level,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal isOpen={isTicketOpen} onClose={() => setIsTicketOpen(false)} title="Submit a ticket">
|
<Modal isOpen={isTicketOpen} onClose={() => setIsTicketOpen(false)} title="Submit a ticket">
|
||||||
<TicketSubmission user={user} page={window.location.href} onClose={() => setIsTicketOpen(false)} />
|
<TicketSubmission user={user} page={router.asPath} onClose={() => setIsTicketOpen(false)} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
@@ -71,11 +105,21 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
|||||||
<h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1>
|
<h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6">
|
<div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6">
|
||||||
|
{user.type === "student" &&
|
||||||
|
badges.map((badge) => (
|
||||||
|
<div
|
||||||
|
key={badge.module}
|
||||||
|
className={`${
|
||||||
|
badge.achieved ? `bg-ielts-${badge.module}` : "bg-mti-gray-anti-flash"
|
||||||
|
} flex h-8 w-8 items-center justify-center rounded-full`}>
|
||||||
|
{badge.icon()}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
{/* OPEN TICKET SYSTEM */}
|
{/* OPEN TICKET SYSTEM */}
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1",
|
"border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1",
|
||||||
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white",
|
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white z-20",
|
||||||
)}
|
)}
|
||||||
data-tip="Submit a help/feedback ticket"
|
data-tip="Submit a help/feedback ticket"
|
||||||
onClick={() => setIsTicketOpen(true)}>
|
onClick={() => setIsTicketOpen(true)}>
|
||||||
@@ -84,7 +128,7 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
|||||||
|
|
||||||
{showExpirationDate() && (
|
{showExpirationDate() && (
|
||||||
<Link
|
<Link
|
||||||
href={disablePaymentPage ? "/payment" : ""}
|
href={!!user.subscriptionExpirationDate && !disablePaymentPage ? "/payment" : ""}
|
||||||
data-tip="Expiry date"
|
data-tip="Expiry date"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none",
|
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none",
|
||||||
@@ -101,8 +145,13 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
|||||||
<Link href={disableNavigation ? "" : "/profile"} className="-md:hidden flex items-center justify-end gap-6">
|
<Link href={disableNavigation ? "" : "/profile"} className="-md:hidden flex items-center justify-end gap-6">
|
||||||
<img src={user.profilePicture} alt={user.name} className="h-10 w-10 rounded-full object-cover" />
|
<img src={user.profilePicture} alt={user.name} className="h-10 w-10 rounded-full object-cover" />
|
||||||
<span className="-md:hidden text-right">
|
<span className="-md:hidden text-right">
|
||||||
{user.type === "corporate" ? `${user.corporateInformation?.companyInformation.name} |` : ""} {user.name} |{" "}
|
{(user.type === "corporate" || user.type === "mastercorporate") && !!user.corporateInformation?.companyInformation?.name
|
||||||
{USER_TYPE_LABELS[user.type]}
|
? `${user.corporateInformation?.companyInformation.name} |`
|
||||||
|
: ""}{" "}
|
||||||
|
{user.name} | {USER_TYPE_LABELS[user.type]}
|
||||||
|
{user.type === "corporate" &&
|
||||||
|
!!user.demographicInformation?.position &&
|
||||||
|
` | ${user.demographicInformation?.position || "N/A"}`}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
|
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
import { DurationUnit } from "@/interfaces/paypal";
|
|
||||||
import {
|
|
||||||
CreateOrderActions,
|
|
||||||
CreateOrderData,
|
|
||||||
OnApproveActions,
|
|
||||||
OnApproveData,
|
|
||||||
OnCancelledActions,
|
|
||||||
OrderResponseBody,
|
|
||||||
} from "@paypal/paypal-js";
|
|
||||||
import {
|
|
||||||
PayPalButtons,
|
|
||||||
PayPalScriptProvider,
|
|
||||||
usePayPalScriptReducer,
|
|
||||||
} from "@paypal/react-paypal-js";
|
|
||||||
import axios from "axios";
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { toast } from "react-toastify";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
clientID: string;
|
|
||||||
currency: string;
|
|
||||||
price: number;
|
|
||||||
duration: number;
|
|
||||||
duration_unit: DurationUnit;
|
|
||||||
loadScript?: boolean;
|
|
||||||
setIsLoading: (isLoading: boolean) => void;
|
|
||||||
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
|
|
||||||
trackingId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PayPalPayment({
|
|
||||||
clientID,
|
|
||||||
price,
|
|
||||||
currency,
|
|
||||||
duration,
|
|
||||||
duration_unit,
|
|
||||||
loadScript,
|
|
||||||
setIsLoading,
|
|
||||||
onSuccess,
|
|
||||||
trackingId,
|
|
||||||
}: Props) {
|
|
||||||
const createOrder = async (
|
|
||||||
data: CreateOrderData,
|
|
||||||
actions: CreateOrderActions
|
|
||||||
): Promise<string> => {
|
|
||||||
if (!trackingId) {
|
|
||||||
throw new Error("trackingId is not set");
|
|
||||||
}
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
return axios
|
|
||||||
.post<OrderResponseBody>("/api/paypal", {
|
|
||||||
currencyCode: currency,
|
|
||||||
price,
|
|
||||||
trackingId,
|
|
||||||
})
|
|
||||||
.then((response) => response.data)
|
|
||||||
.then((data) => data.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onApprove = async (data: OnApproveData, actions: OnApproveActions) => {
|
|
||||||
if (!trackingId) {
|
|
||||||
throw new Error("trackingId is not set");
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = await axios.post<{ ok: boolean; reason?: string }>(
|
|
||||||
"/api/paypal/approve",
|
|
||||||
{ id: data.orderID, duration, duration_unit, trackingId }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (request.status !== 200) {
|
|
||||||
toast.error("Something went wrong, please try again later");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success("Your account has been credited more time!");
|
|
||||||
return onSuccess(duration, duration_unit);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onError = async (data: Record<string, unknown>) => {
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCancel = async (
|
|
||||||
data: Record<string, unknown>,
|
|
||||||
actions: OnCancelledActions
|
|
||||||
) => {
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (trackingId) {
|
|
||||||
return loadScript ? (
|
|
||||||
<PayPalScriptProvider
|
|
||||||
options={{
|
|
||||||
clientId: clientID,
|
|
||||||
currency,
|
|
||||||
intent: "capture",
|
|
||||||
commit: true,
|
|
||||||
vault: true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PayPalButtons
|
|
||||||
className="w-full"
|
|
||||||
style={{ layout: "vertical" }}
|
|
||||||
createOrder={createOrder}
|
|
||||||
onApprove={onApprove}
|
|
||||||
onCancel={onCancel}
|
|
||||||
onError={onError}
|
|
||||||
/>
|
|
||||||
</PayPalScriptProvider>
|
|
||||||
) : (
|
|
||||||
<PayPalButtons
|
|
||||||
className="w-full"
|
|
||||||
style={{ layout: "vertical" }}
|
|
||||||
createOrder={createOrder}
|
|
||||||
onApprove={onApprove}
|
|
||||||
onCancel={onCancel}
|
|
||||||
onError={onError}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
77
src/components/PaymobPayment.tsx
Normal file
77
src/components/PaymobPayment.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import {PaymentIntention} from "@/interfaces/paymob";
|
||||||
|
import {DurationUnit} from "@/interfaces/paypal";
|
||||||
|
import {User} from "@/interfaces/user";
|
||||||
|
import axios from "axios";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import {useState} from "react";
|
||||||
|
import Button from "./Low/Button";
|
||||||
|
import Input from "./Low/Input";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
currency: string;
|
||||||
|
price: number;
|
||||||
|
setIsPaymentLoading: (v: boolean) => void;
|
||||||
|
duration: number;
|
||||||
|
duration_unit: DurationUnit;
|
||||||
|
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PaymobPayment({user, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess}: Props) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleCardPayment = async () => {
|
||||||
|
try {
|
||||||
|
setIsPaymentLoading(true);
|
||||||
|
|
||||||
|
const paymentIntention: PaymentIntention = {
|
||||||
|
amount: price * 1000,
|
||||||
|
currency: "OMR",
|
||||||
|
items: [],
|
||||||
|
payment_methods: [],
|
||||||
|
customer: {
|
||||||
|
email: user.email,
|
||||||
|
first_name: user.name.split(" ")[0],
|
||||||
|
last_name: [...user.name.split(" ")].pop() || "N/A",
|
||||||
|
extras: {
|
||||||
|
re: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
billing_data: {
|
||||||
|
apartment: "N/A",
|
||||||
|
building: "N/A",
|
||||||
|
country: user.demographicInformation?.country || "N/A",
|
||||||
|
email: user.email,
|
||||||
|
first_name: user.name.split(" ")[0],
|
||||||
|
last_name: [...user.name.split(" ")].pop() || "N/A",
|
||||||
|
floor: "N/A",
|
||||||
|
phone_number: user.demographicInformation?.phone || "N/A",
|
||||||
|
state: "N/A",
|
||||||
|
street: "N/A",
|
||||||
|
},
|
||||||
|
extras: {
|
||||||
|
userID: user.id,
|
||||||
|
duration,
|
||||||
|
duration_unit,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await axios.post<{iframeURL: string}>(`/api/paymob`, paymentIntention);
|
||||||
|
|
||||||
|
router.push(response.data.iframeURL);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error starting card payment process:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button isLoading={isLoading} onClick={handleCardPayment}>
|
||||||
|
Select
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/components/PermissionList.tsx
Normal file
82
src/components/PermissionList.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {Permission} from "@/interfaces/permissions";
|
||||||
|
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable, Row} from "@tanstack/react-table";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {convertCamelCaseToReadable} from "@/utils/string";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
permissions: Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<Permission>();
|
||||||
|
|
||||||
|
const defaultColumns = [
|
||||||
|
columnHelper.accessor("type", {
|
||||||
|
header: () => <span>Type</span>,
|
||||||
|
cell: ({row, getValue}) => (
|
||||||
|
<Link
|
||||||
|
href={`/permissions/${row.original.id}`}
|
||||||
|
key={row.id}
|
||||||
|
className="underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer">
|
||||||
|
{convertCamelCaseToReadable(getValue() as string)}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function PermissionList({permissions}: Props) {
|
||||||
|
const table = useReactTable({
|
||||||
|
data: permissions,
|
||||||
|
columns: defaultColumns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupedData: {[key: string]: Row<Permission>[]} = table.getRowModel().rows.reduce((groups: {[key: string]: Row<Permission>[]}, row) => {
|
||||||
|
const parent = row.original.topic;
|
||||||
|
if (!groups[parent]) {
|
||||||
|
groups[parent] = [];
|
||||||
|
}
|
||||||
|
groups[parent].push(row);
|
||||||
|
return groups;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full">
|
||||||
|
<div className="w-full flex flex-col gap-2">
|
||||||
|
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||||
|
<thead>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<th className="py-4 px-4 text-left" key={header.id}>
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody className="px-2">
|
||||||
|
{Object.keys(groupedData).map((parent) => (
|
||||||
|
<React.Fragment key={parent}>
|
||||||
|
<tr>
|
||||||
|
<td className="px-2 py-2 items-center w-fit">
|
||||||
|
<strong>{parent}</strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{groupedData[parent].map((row, i) => (
|
||||||
|
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
src/components/QuestionsModal.tsx
Normal file
121
src/components/QuestionsModal.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
|
import { Fragment, useEffect, useState } from "react";
|
||||||
|
import Button from "./Low/Button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
type?: "module" | "blankQuestions" | "submit";
|
||||||
|
unanswered?: boolean;
|
||||||
|
onClose: (next?: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QuestionsModal({ isOpen, onClose, type = "module", unanswered = false }: Props) {
|
||||||
|
const [isClosing, setIsClosing] = useState(false);
|
||||||
|
|
||||||
|
const blockMultipleClicksClose = (x: boolean) => {
|
||||||
|
if (!isClosing) {
|
||||||
|
setIsClosing(true);
|
||||||
|
onClose(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsClosing(false);
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition show={isOpen} as={Fragment}>
|
||||||
|
<Dialog onClose={() => onClose(false)} className="relative z-50">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0">
|
||||||
|
<div className="fixed inset-0 bg-black/30" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95">
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
|
||||||
|
{type === "module" && (
|
||||||
|
<>
|
||||||
|
<Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title>
|
||||||
|
<span>
|
||||||
|
Please note that you are finishing the current module and once you proceed to the next module, you will no longer be
|
||||||
|
able to change the answers of the current one, including your unanswered questions. <br />
|
||||||
|
<br />
|
||||||
|
Are you sure you want to continue without completing those questions?
|
||||||
|
</span>
|
||||||
|
<div className="w-full flex justify-between mt-8">
|
||||||
|
<Button color="purple" onClick={() => blockMultipleClicksClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" onClick={() => blockMultipleClicksClose(true)} className="max-w-[200px] self-end w-full">
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{type === "blankQuestions" && (
|
||||||
|
<>
|
||||||
|
<Dialog.Title className="font-bold text-2xl">Questions Unanswered</Dialog.Title>
|
||||||
|
<div className="flex flex-col text-xl gap-2">
|
||||||
|
<p>You have left some questions unanswered in the current part.</p>
|
||||||
|
<p>If you wish to continue, you can still access this part later using the navigation bar at the top or the "Back" button.</p>
|
||||||
|
<p>Do you want to proceed to the next part, or would you like to go back and complete the unanswered questions in the current part?</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex justify-between mt-4">
|
||||||
|
<Button color="purple" onClick={() => blockMultipleClicksClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" onClick={() => blockMultipleClicksClose(true)} className="max-w-[200px] self-end w-full">
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{type === "submit" && (
|
||||||
|
<>
|
||||||
|
<Dialog.Title className="font-bold text-3xl text-mti-rose-light">Confirm Submission</Dialog.Title>
|
||||||
|
<span className="text-xl">
|
||||||
|
{unanswered ? (
|
||||||
|
<>
|
||||||
|
By clicking "Submit", you are finalizing your exam with some <b>questions left unanswered</b>. Once you submit, you will not be able to review or change any of your answers, including the unanswered ones. <br />
|
||||||
|
<br />
|
||||||
|
Are you sure you want to submit and complete the exam <b>with unanswered questions</b>?
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
By clicking "Submit", you are finalizing your exam. Once you submit, you will not be able to review or change any of your answers. <br />
|
||||||
|
<br />
|
||||||
|
Are you sure you want to submit and complete the exam?
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="w-full flex justify-between mt-4">
|
||||||
|
<Button color="purple" onClick={() => blockMultipleClicksClose(false)} variant="outline" className="max-w-[200px] self-end w-full !text-xl">
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
<Button color="rose" onClick={() => blockMultipleClicksClose(true)} className="max-w-[200px] self-end w-full !text-xl">
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</div>
|
||||||
|
</Transition.Child>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/components/RadialProgressBar.tsx
Normal file
64
src/components/RadialProgressBar.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
|
||||||
|
interface RadialProgressBarProps {
|
||||||
|
percentage: number;
|
||||||
|
text: string;
|
||||||
|
color: string;
|
||||||
|
spanClassName?: string;
|
||||||
|
size?: number;
|
||||||
|
strokeWidth?: number;
|
||||||
|
strokeOpacity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// https://gist.github.com/eYinka/873be69fae3ef27b103681b8a9f5e379 Omarmarei's answer
|
||||||
|
const RadialProgressBar: React.FC<RadialProgressBarProps> = ({
|
||||||
|
percentage,
|
||||||
|
text,
|
||||||
|
color,
|
||||||
|
spanClassName = "",
|
||||||
|
size = 100,
|
||||||
|
strokeWidth = 10,
|
||||||
|
strokeOpacity = 0.5
|
||||||
|
}) => {
|
||||||
|
const radius = (size - strokeWidth) / 2;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const offset = circumference - (percentage / 100) * circumference;
|
||||||
|
return (
|
||||||
|
<div className='relative flex items-center justify-center' style={{ width: size, height: size}}>
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox={`0 0 ${size} ${size}`
|
||||||
|
}
|
||||||
|
className="circular-progress-bar"
|
||||||
|
>
|
||||||
|
<circle className="circle-bg" stroke="#e6e6e6" strokeWidth={strokeWidth}
|
||||||
|
fill="none"
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
strokeOpacity={strokeOpacity}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
className="circle-progress"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="none"
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||||
|
strokeOpacity={strokeOpacity}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className={clsx('absolute', spanClassName)}>{text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default RadialProgressBar;
|
||||||
48
src/components/SegmentedProgressBar.tsx
Normal file
48
src/components/SegmentedProgressBar.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
interface Segment {
|
||||||
|
percentage: number;
|
||||||
|
subtitle: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
interface SegmentedProgressBarProps {
|
||||||
|
segments: Segment[];
|
||||||
|
height?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
const SegmentedProgressBar: React.FC<SegmentedProgressBarProps> = ({ segments, height=15, className="" }) => {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<div className="relative flex rounded-full overflow-hidden bg-gray-200" style={{height: `${height}px`}}>
|
||||||
|
{segments.map((segment, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={clsx(
|
||||||
|
'h-full opacity-50',
|
||||||
|
'transition-all duration-500 ease-out',
|
||||||
|
`bg-${segment.color}`,
|
||||||
|
{
|
||||||
|
'rounded-l-full': index === 0,
|
||||||
|
'rounded-r-full': index === segments.length - 1,
|
||||||
|
'rounded-none': index !== 0 && index !== segments.length - 1
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
style={{width: `${segment.percentage}%`}}
|
||||||
|
></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex text-sm justify-between">
|
||||||
|
{segments.map((segment, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex flex-col text-center w-fit"
|
||||||
|
>
|
||||||
|
<span className={clsx('font-semibold',`text-${segment.color}`)}>{segment.subtitle}</span>
|
||||||
|
<span>{`${segment.percentage}%`}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default SegmentedProgressBar;
|
||||||
@@ -12,7 +12,10 @@ import {
|
|||||||
BsCloudFill,
|
BsCloudFill,
|
||||||
BsCurrencyDollar,
|
BsCurrencyDollar,
|
||||||
BsClipboardData,
|
BsClipboardData,
|
||||||
|
BsFileLock,
|
||||||
|
BsPeople,
|
||||||
} from "react-icons/bs";
|
} from "react-icons/bs";
|
||||||
|
import {CiDumbbell} from "react-icons/ci";
|
||||||
import {RiLogoutBoxFill} from "react-icons/ri";
|
import {RiLogoutBoxFill} from "react-icons/ri";
|
||||||
import {SlPencil} from "react-icons/sl";
|
import {SlPencil} from "react-icons/sl";
|
||||||
import {FaAward} from "react-icons/fa";
|
import {FaAward} from "react-icons/fa";
|
||||||
@@ -23,16 +26,17 @@ import FocusLayer from "@/components/FocusLayer";
|
|||||||
import {preventNavigation} from "@/utils/navigation.disabled";
|
import {preventNavigation} from "@/utils/navigation.disabled";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import usePreferencesStore from "@/stores/preferencesStore";
|
import usePreferencesStore from "@/stores/preferencesStore";
|
||||||
import {Type} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import useTicketsListener from "@/hooks/useTicketsListener";
|
import useTicketsListener from "@/hooks/useTicketsListener";
|
||||||
|
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
||||||
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
interface Props {
|
interface Props {
|
||||||
path: string;
|
path: string;
|
||||||
navDisabled?: boolean;
|
navDisabled?: boolean;
|
||||||
focusMode?: boolean;
|
focusMode?: boolean;
|
||||||
onFocusLayerMouseEnter?: () => void;
|
onFocusLayerMouseEnter?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
userType?: Type;
|
user: User;
|
||||||
userId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavProps {
|
interface NavProps {
|
||||||
@@ -72,14 +76,13 @@ const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false,
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Sidebar({path, navDisabled = false, focusMode = false, userType, onFocusLayerMouseEnter, className, userId}: Props) {
|
export default function Sidebar({path, navDisabled = false, focusMode = false, user, onFocusLayerMouseEnter, className}: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
|
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
|
||||||
|
|
||||||
const {totalAssignedTickets} = useTicketsListener(userId);
|
const {totalAssignedTickets} = useTicketsListener(user.id);
|
||||||
|
const {permissions} = usePermissions(user.id);
|
||||||
useEffect(() => console.log(totalAssignedTickets), [totalAssignedTickets]);
|
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
axios.post("/api/logout").finally(() => {
|
axios.post("/api/logout").finally(() => {
|
||||||
@@ -98,29 +101,25 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
)}>
|
)}>
|
||||||
<div className="-xl:hidden flex-col gap-3 xl:flex">
|
<div className="-xl:hidden flex-col gap-3 xl:flex">
|
||||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
|
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
|
||||||
{(userType === "student" || userType === "teacher" || userType === "developer") && (
|
{checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExams") && (
|
||||||
<>
|
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={isMinimized} />
|
||||||
<Nav
|
|
||||||
disabled={disableNavigation}
|
|
||||||
Icon={BsFileEarmarkText}
|
|
||||||
label="Exams"
|
|
||||||
path={path}
|
|
||||||
keyPath="/exam"
|
|
||||||
isMinimized={isMinimized}
|
|
||||||
/>
|
|
||||||
<Nav
|
|
||||||
disabled={disableNavigation}
|
|
||||||
Icon={BsPencil}
|
|
||||||
label="Exercises"
|
|
||||||
path={path}
|
|
||||||
keyPath="/exercises"
|
|
||||||
isMinimized={isMinimized}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
|
{checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExercises") && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
|
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={isMinimized} />
|
||||||
{["admin", "developer", "agent", "corporate"].includes(userType || "") && (
|
)}
|
||||||
|
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
|
||||||
|
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
|
||||||
|
)}
|
||||||
|
{checkAccess(user, ["developer", "admin", "teacher", "student"], permissions) && (
|
||||||
|
<Nav disabled={disableNavigation} Icon={BsPeople} label="Groups" path={path} keyPath="/groups" isMinimized={isMinimized} />
|
||||||
|
)}
|
||||||
|
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
|
||||||
|
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
|
||||||
|
)}
|
||||||
|
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
|
||||||
|
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={isMinimized} />
|
||||||
|
)}
|
||||||
|
{checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"], permissions, "viewPaymentRecords") && (
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
Icon={BsCurrencyDollar}
|
Icon={BsCurrencyDollar}
|
||||||
@@ -130,7 +129,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
isMinimized={isMinimized}
|
isMinimized={isMinimized}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{["admin", "developer", "corporate", "teacher"].includes(userType || "") && (
|
{checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]) && (
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
Icon={BsShieldFill}
|
Icon={BsShieldFill}
|
||||||
@@ -140,7 +139,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
isMinimized={isMinimized}
|
isMinimized={isMinimized}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{["admin", "developer", "agent"].includes(userType || "") && (
|
{checkAccess(user, ["admin", "developer", "agent"], permissions, "viewTickets") && (
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
Icon={BsClipboardData}
|
Icon={BsClipboardData}
|
||||||
@@ -151,7 +150,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
badge={totalAssignedTickets}
|
badge={totalAssignedTickets}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{userType === "developer" && (
|
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
Icon={BsCloudFill}
|
Icon={BsCloudFill}
|
||||||
@@ -161,22 +160,59 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
isMinimized={isMinimized}
|
isMinimized={isMinimized}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate", "agent"]) && (
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsFileLock}
|
||||||
|
label="Permissions"
|
||||||
|
path={path}
|
||||||
|
keyPath="/permissions"
|
||||||
|
isMinimized={isMinimized}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
||||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
|
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
|
||||||
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
|
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
|
||||||
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
|
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
|
||||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
|
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
|
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
|
||||||
{userType !== "student" && (
|
)}
|
||||||
|
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
|
||||||
|
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
|
||||||
|
)}
|
||||||
|
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
|
||||||
|
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={true} />
|
||||||
|
)}
|
||||||
|
{checkAccess(user, getTypesOfUser(["student"])) && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
|
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
|
||||||
)}
|
)}
|
||||||
{userType === "developer" && (
|
{checkAccess(user, getTypesOfUser(["student"])) && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsCloudFill} label="Generation" path={path} keyPath="/generation" isMinimized={true} />
|
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Permissions" path={path} keyPath="/permissions" isMinimized={true} />
|
||||||
|
)}
|
||||||
|
{checkAccess(user, ["developer"]) && (
|
||||||
|
<>
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsCloudFill}
|
||||||
|
label="Generation"
|
||||||
|
path={path}
|
||||||
|
keyPath="/generation"
|
||||||
|
isMinimized={true}
|
||||||
|
/>
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsFileLock}
|
||||||
|
label="Permissions"
|
||||||
|
path={path}
|
||||||
|
keyPath="/permissions"
|
||||||
|
isMinimized={true}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="fixed bottom-12 flex flex-col gap-0">
|
<div className="2xl:fixed bottom-12 flex flex-col gap-0 -2xl:mt-8">
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={1}
|
tabIndex={1}
|
||||||
|
|||||||
@@ -1,53 +1,132 @@
|
|||||||
import {FillBlanksExercise} from "@/interfaces/exam";
|
import {FillBlanksExercise, FillBlanksMCOption, ShuffleMap} from "@/interfaces/exam";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import reactStringReplace from "react-string-replace";
|
import reactStringReplace from "react-string-replace";
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import {Fragment} from "react";
|
import {Fragment} from "react";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
|
||||||
|
export default function FillBlanksSolutions({id, type, prompt, solutions, words, text, onNext, onBack}: FillBlanksExercise & CommonProps) {
|
||||||
|
const storeUserSolutions = useExamStore((state) => state.userSolutions);
|
||||||
|
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
|
||||||
|
|
||||||
|
const correctUserSolutions = storeUserSolutions.find((solution) => solution.exercise === id)?.solutions;
|
||||||
|
|
||||||
|
const shuffles = useExamStore((state) => state.shuffles);
|
||||||
|
|
||||||
export default function FillBlanksSolutions({id, type, prompt, solutions, text, userSolutions, onNext, onBack}: FillBlanksExercise & CommonProps) {
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||||
const correct = userSolutions.filter(
|
const correct = correctUserSolutions!.filter((x) => {
|
||||||
(x) => solutions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false,
|
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
|
||||||
).length;
|
if (!solution) return false;
|
||||||
const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
|
||||||
|
|
||||||
|
const option = words.find((w) => {
|
||||||
|
if (typeof w === "string") {
|
||||||
|
return w.toLowerCase() === x.solution.toLowerCase();
|
||||||
|
} else if ("letter" in w) {
|
||||||
|
return w.letter.toLowerCase() === x.solution.toLowerCase();
|
||||||
|
} else {
|
||||||
|
return w.id.toString() === x.id.toString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!option) return false;
|
||||||
|
|
||||||
|
if (typeof option === "string") {
|
||||||
|
return solution.toLowerCase() === option.toLowerCase();
|
||||||
|
} else if ("letter" in option) {
|
||||||
|
return solution.toLowerCase() === option.word.toLowerCase();
|
||||||
|
} else if ("options" in option) {
|
||||||
|
return option.options[solution as keyof typeof option.options] == x.solution;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}).length;
|
||||||
|
const missing = total - correctUserSolutions!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
|
||||||
|
return Array.isArray(words) && words.every((word) => word && typeof word === "object" && "id" in word && "options" in word);
|
||||||
|
};
|
||||||
|
|
||||||
const renderLines = (line: string) => {
|
const renderLines = (line: string) => {
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||||
const id = match.replaceAll(/[\{\}]/g, "");
|
const questionId = match.replaceAll(/[\{\}]/g, "");
|
||||||
const userSolution = userSolutions.find((x) => x.id === id);
|
const userSolution = correctUserSolutions!.find((x) => x.id.toString() === questionId.toString());
|
||||||
const solution = solutions.find((x) => x.id === id)!;
|
const answerSolution = solutions.find((sol) => sol.id.toString() === questionId.toString())!.solution;
|
||||||
|
const questionShuffleMap = shuffles.find((x) => x.exerciseID == id)?.shuffles.find((y) => y.questionID == questionId);
|
||||||
|
const newAnswerSolution = questionShuffleMap
|
||||||
|
? questionShuffleMap.map[answerSolution].toLowerCase()
|
||||||
|
: answerSolution.toLowerCase();
|
||||||
|
|
||||||
if (!userSolution) {
|
if (!userSolution) {
|
||||||
|
let answerText;
|
||||||
|
if (typeCheckWordsMC(words)) {
|
||||||
|
const options = words.find((x) => x.id.toString() === questionId.toString());
|
||||||
|
const correctKey = Object.keys(options!.options).find((key) => key.toLowerCase() === newAnswerSolution);
|
||||||
|
answerText = options!.options[correctKey as keyof typeof options];
|
||||||
|
} else {
|
||||||
|
answerText = answerSolution;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"rounded-full hover:text-white hover:bg-mti-gray-davy transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-gray-davy",
|
"rounded-full hover:text-white hover:bg-mti-gray-davy transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-gray-davy",
|
||||||
)}>
|
)}>
|
||||||
{solution?.solution}
|
{answerText}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userSolution.solution === solution.solution) {
|
const userSolutionWord = words.find((w) =>
|
||||||
|
typeof w === "string"
|
||||||
|
? w.toLowerCase() === userSolution.solution.toLowerCase()
|
||||||
|
: "letter" in w
|
||||||
|
? w.letter.toLowerCase() === userSolution.solution.toLowerCase()
|
||||||
|
: "options" in w
|
||||||
|
? w.id === userSolution.questionId
|
||||||
|
: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const userSolutionText =
|
||||||
|
typeof userSolutionWord === "string"
|
||||||
|
? userSolutionWord
|
||||||
|
: userSolutionWord && "letter" in userSolutionWord
|
||||||
|
? userSolutionWord.word
|
||||||
|
: userSolutionWord && "options" in userSolutionWord
|
||||||
|
? userSolution.solution
|
||||||
|
: userSolution.solution;
|
||||||
|
|
||||||
|
let correct;
|
||||||
|
let solutionText;
|
||||||
|
if (typeCheckWordsMC(words)) {
|
||||||
|
const options = words.find((x) => x.id.toString() === questionId.toString());
|
||||||
|
if (options) {
|
||||||
|
const correctKey = Object.keys(options.options).find((key) => key.toLowerCase() === newAnswerSolution);
|
||||||
|
correct = userSolution.solution == options.options[correctKey as keyof typeof options.options];
|
||||||
|
solutionText = options.options[correctKey as keyof typeof options.options] || answerSolution;
|
||||||
|
} else {
|
||||||
|
correct = false;
|
||||||
|
solutionText = answerSolution;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
correct = userSolutionText === answerSolution;
|
||||||
|
solutionText = answerSolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (correct) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
|
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
|
||||||
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
|
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
|
||||||
)}>
|
)}>
|
||||||
{solution.solution}
|
{solutionText}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
} else {
|
||||||
|
|
||||||
if (userSolution.solution !== solution.solution) {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -55,7 +134,7 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
|
|||||||
"rounded-full hover:text-white hover:bg-mti-rose transition duration-300 ease-in-out my-1 mr-1",
|
"rounded-full hover:text-white hover:bg-mti-rose transition duration-300 ease-in-out my-1 mr-1",
|
||||||
userSolution && "px-5 py-2 text-center text-white bg-mti-rose-light",
|
userSolution && "px-5 py-2 text-center text-white bg-mti-rose-light",
|
||||||
)}>
|
)}>
|
||||||
{userSolution.solution}
|
{userSolutionText}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -63,7 +142,7 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
|
|||||||
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
|
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
|
||||||
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
|
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
|
||||||
)}>
|
)}>
|
||||||
{solution.solution}
|
{solutionText}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -74,18 +153,28 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
<div className="flex justify-between w-full gap-8">
|
||||||
<span className="text-sm w-full leading-6">
|
<Button
|
||||||
{prompt.split("\\n").map((line, index) => (
|
color="purple"
|
||||||
<Fragment key={index}>
|
variant="outline"
|
||||||
{line}
|
onClick={() => onBack({exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type})}
|
||||||
<br />
|
className="max-w-[200px] w-full"
|
||||||
</Fragment>
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
))}
|
Back
|
||||||
</span>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
||||||
{userSolutions &&
|
{correctUserSolutions &&
|
||||||
text.split("\\n").map((line, index) => (
|
text.split("\\n").map((line, index) => (
|
||||||
<p key={index}>
|
<p key={index}>
|
||||||
{renderLines(line)}
|
{renderLines(line)}
|
||||||
@@ -113,18 +202,19 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
|
|||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
onClick={() => onBack({exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type})}
|
||||||
className="max-w-[200px] w-full">
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
onClick={() => onNext({exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type})}
|
||||||
className="max-w-[200px] self-end w-full">
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,18 @@ export default function InteractiveSpeaking({
|
|||||||
onBack,
|
onBack,
|
||||||
}: InteractiveSpeakingExercise & CommonProps) {
|
}: InteractiveSpeakingExercise & CommonProps) {
|
||||||
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
|
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
|
||||||
const [diffNumber, setDiffNumber] = useState<0 | 1 | 2 | 3>(0);
|
const [diffNumber, setDiffNumber] = useState(0);
|
||||||
|
|
||||||
|
const tooltips: {[key: string]: string} = {
|
||||||
|
"Grammatical Range and Accuracy":
|
||||||
|
"Assesses the variety and correctness of grammatical structures used. A higher score indicates a wide range of complex and accurate grammar; a lower score suggests the need for more basic grammar practice.",
|
||||||
|
"Fluency and Coherence":
|
||||||
|
"Evaluates smoothness and logical flow of speech. A higher score means natural, effortless speech and clear idea progression; a lower score indicates frequent pauses and difficulty in maintaining coherence.",
|
||||||
|
Pronunciation:
|
||||||
|
"Measures clarity and accuracy of spoken words. A higher score reflects clear, well-articulated speech with correct intonation; a lower score shows challenges in being understood.",
|
||||||
|
"Lexical Resource":
|
||||||
|
"Looks at the range and appropriateness of vocabulary. A higher score demonstrates a rich and precise vocabulary; a lower score suggests limited vocabulary usage and appropriateness.",
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
|
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
|
||||||
@@ -44,7 +55,41 @@ export default function InteractiveSpeaking({
|
|||||||
}, [userSolutions]);
|
}, [userSolutions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||||
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
onBack({
|
||||||
|
exercise: id,
|
||||||
|
solutions: userSolutions,
|
||||||
|
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() =>
|
||||||
|
onNext({
|
||||||
|
exercise: id,
|
||||||
|
solutions: userSolutions,
|
||||||
|
score: {
|
||||||
|
total: 100,
|
||||||
|
missing: 0,
|
||||||
|
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
||||||
|
},
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Modal title={`Correction (Prompt ${diffNumber})`} isOpen={diffNumber !== 0} onClose={() => setDiffNumber(0)}>
|
<Modal title={`Correction (Prompt ${diffNumber})`} isOpen={diffNumber !== 0} onClose={() => setDiffNumber(0)}>
|
||||||
<>
|
<>
|
||||||
{userSolutions &&
|
{userSolutions &&
|
||||||
@@ -115,13 +160,13 @@ export default function InteractiveSpeaking({
|
|||||||
{userSolutions &&
|
{userSolutions &&
|
||||||
userSolutions.length > 0 &&
|
userSolutions.length > 0 &&
|
||||||
userSolutions[0].evaluation &&
|
userSolutions[0].evaluation &&
|
||||||
userSolutions[0].evaluation[`transcript_${(index + 1) as 1 | 2 | 3}`] &&
|
userSolutions[0].evaluation[`transcript_${index + 1}`] &&
|
||||||
userSolutions[0].evaluation[`fixed_text_${(index + 1) as 1 | 2 | 3}`] && (
|
userSolutions[0].evaluation[`fixed_text_${index + 1}`] && (
|
||||||
<Button
|
<Button
|
||||||
className="w-full max-w-[180px] !py-2 self-center"
|
className="w-full max-w-[180px] !py-2 self-center"
|
||||||
color="pink"
|
color="pink"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setDiffNumber((index + 1) as 1 | 2 | 3)}>
|
onClick={() => setDiffNumber(index + 1)}>
|
||||||
View Correction
|
View Correction
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -132,16 +177,38 @@ export default function InteractiveSpeaking({
|
|||||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<div className="flex gap-4 px-1">
|
<div className="flex gap-4 px-1">
|
||||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key, index) => {
|
||||||
<div className="bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2" key={key}>
|
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
||||||
{key}: Level {userSolutions[0].evaluation!.task_response[key]}
|
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||||
</div>
|
|
||||||
))}
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
|
||||||
|
index === 0 && "tooltip-right",
|
||||||
|
)}
|
||||||
|
key={key}
|
||||||
|
data-tip={tooltips[key] || "No additional information available"}>
|
||||||
|
{key}: Level {grade}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{userSolutions[0].evaluation &&
|
{userSolutions[0].evaluation &&
|
||||||
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length === 3 ? (
|
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length > 0 ? (
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
||||||
|
<Tab
|
||||||
|
className={({selected}) =>
|
||||||
|
clsx(
|
||||||
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||||
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
General Feedback
|
||||||
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
className={({selected}) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
@@ -153,59 +220,66 @@ export default function InteractiveSpeaking({
|
|||||||
}>
|
}>
|
||||||
Evaluation
|
Evaluation
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
{Object.keys(userSolutions[0].evaluation)
|
||||||
className={({selected}) =>
|
.filter((x) => x.startsWith("perfect_answer"))
|
||||||
clsx(
|
.map((key, index) => (
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
<Tab
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
key={key}
|
||||||
"transition duration-300 ease-in-out",
|
className={({selected}) =>
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
clsx(
|
||||||
)
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||||
}>
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
Recommended Answer (Prompt 1)
|
"transition duration-300 ease-in-out",
|
||||||
</Tab>
|
selected
|
||||||
<Tab
|
? "bg-white shadow"
|
||||||
className={({selected}) =>
|
: "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||||
clsx(
|
)
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
}>
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
Recommended Answer
|
||||||
"transition duration-300 ease-in-out",
|
<br />
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
(Prompt {index + 1})
|
||||||
)
|
</Tab>
|
||||||
}>
|
))}
|
||||||
Recommended Answer (Prompt 2)
|
|
||||||
</Tab>
|
|
||||||
<Tab
|
|
||||||
className={({selected}) =>
|
|
||||||
clsx(
|
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
Recommended Answer (Prompt 3)
|
|
||||||
</Tab>
|
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels>
|
<Tab.Panels>
|
||||||
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
||||||
|
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
||||||
|
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit",
|
||||||
|
)}
|
||||||
|
key={key}>
|
||||||
|
{key}: Level {grade}
|
||||||
|
</div>
|
||||||
|
{typeof taskResponse !== "number" && (
|
||||||
|
<span className="px-2 py-2">{taskResponse.comment}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Tab.Panel>
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
{Object.keys(userSolutions[0].evaluation)
|
||||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
.filter((x) => x.startsWith("perfect_answer"))
|
||||||
{userSolutions[0].evaluation!.perfect_answer_1!.replaceAll(/\s{2,}/g, "\n\n")}
|
.map((key, index) => (
|
||||||
</span>
|
<Tab.Panel key={key} className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
</Tab.Panel>
|
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
{userSolutions[0].evaluation![`perfect_answer_${index + 1}`].answer.replaceAll(
|
||||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
/\s{2,}/g,
|
||||||
{userSolutions[0].evaluation!.perfect_answer_2!.replaceAll(/\s{2,}/g, "\n\n")}
|
"\n\n",
|
||||||
</span>
|
)}
|
||||||
</Tab.Panel>
|
</span>
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
</Tab.Panel>
|
||||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
))}
|
||||||
{userSolutions[0].evaluation!.perfect_answer_3!.replaceAll(/\s{2,}/g, "\n\n")}
|
|
||||||
</span>
|
|
||||||
</Tab.Panel>
|
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
) : (
|
) : (
|
||||||
@@ -251,6 +325,6 @@ export default function InteractiveSpeaking({
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {MatchSentencesExercise} from "@/interfaces/exam";
|
import {MatchSentenceExerciseSentence, MatchSentencesExercise} from "@/interfaces/exam";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import LineTo from "react-lineto";
|
import LineTo from "react-lineto";
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
@@ -8,6 +8,49 @@ import Icon from "@mdi/react";
|
|||||||
import {Fragment} from "react";
|
import {Fragment} from "react";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import Xarrow from "react-xarrows";
|
import Xarrow from "react-xarrows";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
|
||||||
|
function QuestionSolutionArea({
|
||||||
|
question,
|
||||||
|
userSolution,
|
||||||
|
}: {
|
||||||
|
question: MatchSentenceExerciseSentence;
|
||||||
|
userSolution?: {question: string; option: string};
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="flex items-center gap-3 cursor-pointer col-span-2">
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"text-white w-8 h-8 rounded-full z-10",
|
||||||
|
!userSolution
|
||||||
|
? "bg-mti-gray-davy"
|
||||||
|
: userSolution.option.toString() === question.solution.toString()
|
||||||
|
? "bg-mti-purple"
|
||||||
|
: "bg-mti-rose",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
)}>
|
||||||
|
{question.id}
|
||||||
|
</button>
|
||||||
|
<span>{question.sentence}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"w-56 h-10 border rounded-xl items-center justify-center flex gap-3 px-2",
|
||||||
|
!userSolution
|
||||||
|
? "border-mti-gray-davy"
|
||||||
|
: userSolution.option.toString() === question.solution.toString()
|
||||||
|
? "border-mti-purple"
|
||||||
|
: "border-mti-rose",
|
||||||
|
)}>
|
||||||
|
<span className="line-through">
|
||||||
|
{userSolution && userSolution?.option.toString() !== question.solution.toString() && `Paragraph ${userSolution.option}`}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">Paragraph {question.solution}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function MatchSentencesSolutions({
|
export default function MatchSentencesSolutions({
|
||||||
id,
|
id,
|
||||||
@@ -19,6 +62,8 @@ export default function MatchSentencesSolutions({
|
|||||||
onNext,
|
onNext,
|
||||||
onBack,
|
onBack,
|
||||||
}: MatchSentencesExercise & CommonProps) {
|
}: MatchSentencesExercise & CommonProps) {
|
||||||
|
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = sentences.length;
|
const total = sentences.length;
|
||||||
const correct = userSolutions.filter(
|
const correct = userSolutions.filter(
|
||||||
@@ -30,8 +75,26 @@ export default function MatchSentencesSolutions({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
@@ -40,57 +103,18 @@ export default function MatchSentencesSolutions({
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
<div className="flex flex-col gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{sentences.map(({sentence, id, solution}) => (
|
{sentences.map((question) => (
|
||||||
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
|
<QuestionSolutionArea
|
||||||
<span>{sentence} </span>
|
question={question}
|
||||||
<button
|
userSolution={userSolutions.find((x) => x.question.toString() === question.id.toString())}
|
||||||
id={id}
|
key={`question_${question.id}`}
|
||||||
className={clsx(
|
|
||||||
"w-8 h-8 rounded-full z-10 text-white",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
!userSolutions.find((x) => x.question.toString() === id.toString()) && "!bg-mti-gray-davy",
|
|
||||||
userSolutions.find((x) => x.question.toString() === id.toString())?.option === solution && "bg-mti-purple",
|
|
||||||
userSolutions.find((x) => x.question.toString() === id.toString())?.option !== solution && "bg-mti-rose",
|
|
||||||
)}>
|
|
||||||
{id}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{options.map(({sentence, id}) => (
|
|
||||||
<div key={`answer_${id}`} className={clsx("flex items-center justify-start gap-2 cursor-pointer")}>
|
|
||||||
<button
|
|
||||||
id={id}
|
|
||||||
className={clsx(
|
|
||||||
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
)}>
|
|
||||||
{id}
|
|
||||||
</button>
|
|
||||||
<span>{sentence}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{userSolutions &&
|
|
||||||
sentences.map((sentence, index) => (
|
|
||||||
<Xarrow
|
|
||||||
key={index}
|
|
||||||
start={sentence.id}
|
|
||||||
end={sentence.solution}
|
|
||||||
lineColor={
|
|
||||||
!userSolutions.find((x) => x.question === sentence.id)
|
|
||||||
? "#CC5454"
|
|
||||||
: userSolutions.find((x) => x.question === sentence.id)?.option === sentence.solution
|
|
||||||
? "#7872BF"
|
|
||||||
: "#CC5454"
|
|
||||||
}
|
|
||||||
showHead={false}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-purple" /> Correct
|
<div className="w-4 h-4 rounded-full bg-mti-purple" /> Correct
|
||||||
@@ -109,7 +133,8 @@ export default function MatchSentencesSolutions({
|
|||||||
color="purple"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
className="max-w-[200px] w-full">
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -120,6 +145,6 @@ export default function MatchSentencesSolutions({
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,87 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
import {MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap} from "@/interfaces/exam";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useEffect, useState} from "react";
|
import reactStringReplace from "react-string-replace";
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
|
import {v4} from "uuid";
|
||||||
|
|
||||||
function Question({
|
function Question({
|
||||||
|
id,
|
||||||
variant,
|
variant,
|
||||||
prompt,
|
prompt,
|
||||||
solution,
|
solution,
|
||||||
options,
|
options,
|
||||||
userSolution,
|
userSolution,
|
||||||
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
||||||
|
const {userSolutions} = useExamStore((state) => state);
|
||||||
|
|
||||||
|
const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => {
|
||||||
|
if (foundMap) return foundMap;
|
||||||
|
return userSolution.shuffleMaps?.find((map) => map.questionID === id) || null;
|
||||||
|
}, null as ShuffleMap | null);
|
||||||
|
|
||||||
|
const newSolution = questionShuffleMap ? questionShuffleMap?.map[solution] : solution;
|
||||||
|
|
||||||
|
const renderPrompt = (prompt: string) => {
|
||||||
|
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
|
||||||
|
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
|
||||||
|
return word.length > 0 ? <u>{word}</u> : null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const optionColor = (option: string) => {
|
const optionColor = (option: string) => {
|
||||||
if (option === solution && !userSolution) {
|
if (option === newSolution && !userSolution) {
|
||||||
return "!border-mti-gray-davy !text-mti-gray-davy";
|
return "!bg-mti-gray-davy !text-white";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (option === solution) {
|
if (option === newSolution) {
|
||||||
return "!border-mti-purple-light !text-mti-purple-light";
|
return "!bg-mti-purple-light !text-white";
|
||||||
}
|
}
|
||||||
|
|
||||||
return userSolution === option ? "!border-mti-rose-light !text-mti-rose-light" : "";
|
return userSolution === option ? "!bg-mti-rose-light !text-white" : "";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<span>{prompt}</span>
|
{isNaN(Number(id)) ? (
|
||||||
<div className="grid grid-cols-4 gap-4 place-items-center">
|
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||||
|
) : (
|
||||||
|
<span className="text-lg" key={v4()}>
|
||||||
|
<>
|
||||||
|
{id} -{" "}
|
||||||
|
<span className="text-lg" key={v4()}>
|
||||||
|
{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}{" "}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-4 justify-between">
|
||||||
{variant === "image" &&
|
{variant === "image" &&
|
||||||
options.map((option) => (
|
options.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={option.id}
|
key={option?.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative",
|
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative select-none",
|
||||||
optionColor(option.id),
|
optionColor(option!.id),
|
||||||
)}>
|
)}>
|
||||||
<span className={clsx("text-sm", solution !== option.id && userSolution !== option.id && "opacity-50")}>{option.id}</span>
|
<span className={clsx("text-sm", newSolution !== option?.id && userSolution !== option?.id && "opacity-50")}>
|
||||||
<img src={option.src!} alt={`Option ${option.id}`} />
|
{option?.id}
|
||||||
|
</span>
|
||||||
|
{"src" in option && <img src={option?.src!} alt={`Option ${option?.id}`} />}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{variant === "text" &&
|
{variant === "text" &&
|
||||||
options.map((option) => (
|
options.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={option.id}
|
key={option?.id}
|
||||||
className={clsx("flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm", optionColor(option.id))}>
|
className={clsx(
|
||||||
<span className="font-semibold">{option.id}.</span>
|
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none",
|
||||||
<span>{option.text}</span>
|
optionColor(option!.id),
|
||||||
|
)}>
|
||||||
|
<span className="font-semibold">{option?.id}.</span>
|
||||||
|
<span>{option?.text}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -54,37 +89,32 @@ function Question({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MultipleChoice({
|
export default function MultipleChoice({id, type, prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
||||||
id,
|
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
|
||||||
type,
|
|
||||||
prompt,
|
const stats = useExamStore((state) => state.userSolutions);
|
||||||
questions,
|
|
||||||
userSolutions,
|
|
||||||
updateIndex,
|
|
||||||
onNext,
|
|
||||||
onBack,
|
|
||||||
}: MultipleChoiceExercise & CommonProps) {
|
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = questions.length;
|
const total = questions.length;
|
||||||
const correct = userSolutions.filter(
|
const questionShuffleMap = stats.find((x) => x.exercise == id)?.shuffleMaps;
|
||||||
(x) => questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
|
const correct = userSolutions.filter((x) => {
|
||||||
).length;
|
if (questionShuffleMap) {
|
||||||
|
const shuffleMap = questionShuffleMap.find((y) => y.questionID === x.question);
|
||||||
|
const originalSol = questions.find((y) => y.id.toString() === x.question.toString())?.solution!;
|
||||||
|
return x.option == shuffleMap?.map[originalSol];
|
||||||
|
} else {
|
||||||
|
return questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false;
|
||||||
|
}
|
||||||
|
}).length;
|
||||||
const missing = total - userSolutions.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length;
|
const missing = total - userSolutions.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length;
|
||||||
|
|
||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (updateIndex) updateIndex(questionIndex);
|
|
||||||
}, [questionIndex, updateIndex]);
|
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
if (questionIndex === questions.length - 1) {
|
if (questionIndex + 1 >= questions.length - 1) {
|
||||||
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
||||||
} else {
|
} else {
|
||||||
setQuestionIndex((prev) => prev + 1);
|
setQuestionIndex(questionIndex + 2);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -92,22 +122,49 @@ export default function MultipleChoice({
|
|||||||
if (questionIndex === 0) {
|
if (questionIndex === 0) {
|
||||||
onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
||||||
} else {
|
} else {
|
||||||
setQuestionIndex((prev) => prev - 1);
|
setQuestionIndex(questionIndex - 2);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-4 w-full h-full mb-20">
|
<div className="flex justify-between w-full gap-8">
|
||||||
<div className="flex flex-col gap-2 mt-4 h-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
<Button
|
||||||
<span className="text-xl font-semibold">{prompt}</span>
|
color="purple"
|
||||||
{userSolutions && questionIndex < questions.length && (
|
variant="outline"
|
||||||
<Question
|
onClick={back}
|
||||||
{...questions[questionIndex]}
|
className="max-w-[200px] w-full"
|
||||||
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
/>
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 w-full h-full mb-20 mt-4">
|
||||||
|
<div className="flex flex-col gap-4 mt-2">
|
||||||
|
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
|
{/*<span className="text-xl font-semibold">{prompt}</span>*/}
|
||||||
|
{userSolutions && questionIndex < questions.length && (
|
||||||
|
<Question
|
||||||
|
{...questions[questionIndex]}
|
||||||
|
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{userSolutions && questionIndex + 1 < questions.length && (
|
||||||
|
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
|
<Question
|
||||||
|
{...questions[questionIndex + 1]}
|
||||||
|
userSolution={userSolutions.find((x) => questions[questionIndex + 1].id === x.question)?.option}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-purple" />
|
<div className="w-4 h-4 rounded-full bg-mti-purple" />
|
||||||
@@ -125,7 +182,12 @@ export default function MultipleChoice({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full">
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={back}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -133,6 +195,6 @@ export default function MultipleChoice({
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
|
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
|
||||||
|
const solution = userSolutions[0].solution;
|
||||||
|
|
||||||
|
if (solution.startsWith("https://")) return setSolutionURL(solution);
|
||||||
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
|
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
|
||||||
const blob = new Blob([data], {type: "audio/wav"});
|
const blob = new Blob([data], {type: "audio/wav"});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -29,8 +32,53 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
}
|
}
|
||||||
}, [userSolutions]);
|
}, [userSolutions]);
|
||||||
|
|
||||||
|
const tooltips: {[key: string]: string} = {
|
||||||
|
"Grammatical Range and Accuracy":
|
||||||
|
"Assesses the variety and correctness of grammatical structures used. A higher score indicates a wide range of complex and accurate grammar; a lower score suggests the need for more basic grammar practice.",
|
||||||
|
"Fluency and Coherence":
|
||||||
|
"Evaluates smoothness and logical flow of speech. A higher score means natural, effortless speech and clear idea progression; a lower score indicates frequent pauses and difficulty in maintaining coherence.",
|
||||||
|
Pronunciation:
|
||||||
|
"Measures clarity and accuracy of spoken words. A higher score reflects clear, well-articulated speech with correct intonation; a lower score shows challenges in being understood.",
|
||||||
|
"Lexical Resource":
|
||||||
|
"Looks at the range and appropriateness of vocabulary. A higher score demonstrates a rich and precise vocabulary; a lower score suggests limited vocabulary usage and appropriateness.",
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||||
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
onBack({
|
||||||
|
exercise: id,
|
||||||
|
solutions: userSolutions,
|
||||||
|
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() =>
|
||||||
|
onNext({
|
||||||
|
exercise: id,
|
||||||
|
solutions: userSolutions,
|
||||||
|
score: {
|
||||||
|
total: 100,
|
||||||
|
missing: 0,
|
||||||
|
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
||||||
|
},
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}>
|
<Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}>
|
||||||
<>
|
<>
|
||||||
{userSolutions &&
|
{userSolutions &&
|
||||||
@@ -123,16 +171,38 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<div className="flex gap-4 px-1">
|
<div className="flex gap-4 px-1">
|
||||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key, index) => {
|
||||||
<div className="bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2" key={key}>
|
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
||||||
{key}: Level {userSolutions[0].evaluation!.task_response[key]}
|
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||||
</div>
|
|
||||||
))}
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
|
||||||
|
index === 0 && "tooltip-right",
|
||||||
|
)}
|
||||||
|
key={key}
|
||||||
|
data-tip={tooltips[key] || "No additional information available"}>
|
||||||
|
{key}: Level {grade}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{userSolutions[0].evaluation &&
|
{userSolutions[0].evaluation &&
|
||||||
(userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? (
|
(userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? (
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
||||||
|
<Tab
|
||||||
|
className={({selected}) =>
|
||||||
|
clsx(
|
||||||
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||||
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
General Feedback
|
||||||
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
className={({selected}) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
@@ -157,9 +227,35 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
</Tab>
|
</Tab>
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels>
|
<Tab.Panels>
|
||||||
|
{/* General Feedback */}
|
||||||
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
||||||
|
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
||||||
|
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit",
|
||||||
|
)}
|
||||||
|
key={key}>
|
||||||
|
{key}: Level {grade}
|
||||||
|
</div>
|
||||||
|
{typeof taskResponse !== "number" && (
|
||||||
|
<span className="px-2 py-2">{taskResponse.comment}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Tab.Panel>
|
||||||
|
{/* Evaluation */}
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
|
{/* Recommended Answer */}
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||||
{userSolutions[0].evaluation!.perfect_answer &&
|
{userSolutions[0].evaluation!.perfect_answer &&
|
||||||
@@ -213,6 +309,6 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import reactStringReplace from "react-string-replace";
|
|||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import {Fragment} from "react";
|
import {Fragment} from "react";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
|
||||||
type Solution = "true" | "false" | "not_given";
|
type Solution = "true" | "false" | "not_given";
|
||||||
|
|
||||||
export default function TrueFalseSolution({prompt, type, id, questions, userSolutions, onNext, onBack}: TrueFalseExercise & CommonProps) {
|
export default function TrueFalseSolution({prompt, type, id, questions, userSolutions, onNext, onBack}: TrueFalseExercise & CommonProps) {
|
||||||
|
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = questions.length || 0;
|
const total = questions.length || 0;
|
||||||
const correct = userSolutions.filter(
|
const correct = userSolutions.filter(
|
||||||
@@ -37,8 +40,26 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
@@ -121,7 +142,8 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
|||||||
color="purple"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
className="max-w-[200px] w-full">
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -132,6 +154,6 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import reactStringReplace from "react-string-replace";
|
|||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
|
||||||
function Blank({
|
function Blank({
|
||||||
id,
|
id,
|
||||||
@@ -49,15 +50,11 @@ function Blank({
|
|||||||
{userSolution && !isUserSolutionCorrect() && (
|
{userSolution && !isUserSolutionCorrect() && (
|
||||||
<div
|
<div
|
||||||
className="py-2 px-3 rounded-2xl w-fit focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light"
|
className="py-2 px-3 rounded-2xl w-fit focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light"
|
||||||
placeholder={id}
|
|
||||||
contentEditable={disabled}>
|
contentEditable={disabled}>
|
||||||
{userSolution}
|
{userSolution}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())} contentEditable={disabled}>
|
||||||
className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())}
|
|
||||||
placeholder={id}
|
|
||||||
contentEditable={disabled}>
|
|
||||||
{!solutions ? userInput : solutions.join(" / ")}
|
{!solutions ? userInput : solutions.join(" / ")}
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
@@ -75,6 +72,8 @@ export default function WriteBlanksSolutions({
|
|||||||
onNext,
|
onNext,
|
||||||
onBack,
|
onBack,
|
||||||
}: WriteBlanksExercise & CommonProps) {
|
}: WriteBlanksExercise & CommonProps) {
|
||||||
|
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||||
const correct = userSolutions.filter(
|
const correct = userSolutions.filter(
|
||||||
@@ -106,8 +105,26 @@ export default function WriteBlanksSolutions({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
@@ -146,7 +163,8 @@ export default function WriteBlanksSolutions({
|
|||||||
color="purple"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
className="max-w-[200px] w-full">
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -157,6 +175,6 @@ export default function WriteBlanksSolutions({
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,64 @@ import {Dialog, Tab, Transition} from "@headlessui/react";
|
|||||||
import {writingReverseMarking} from "@/utils/score";
|
import {writingReverseMarking} from "@/utils/score";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
||||||
|
import useUser from "@/hooks/useUser";
|
||||||
|
import AIDetection from "../AIDetection";
|
||||||
|
|
||||||
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
|
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [showDiff, setShowDiff] = useState(false);
|
const [showDiff, setShowDiff] = useState(false);
|
||||||
|
|
||||||
|
const {user} = useUser();
|
||||||
|
|
||||||
|
const aiEval = userSolutions && userSolutions.length > 0 ? userSolutions[0].evaluation?.ai_detection : undefined;
|
||||||
|
|
||||||
|
const tooltips: {[key: string]: string} = {
|
||||||
|
"Lexical Resource":
|
||||||
|
"Assesses the diversity and accuracy of vocabulary used. A higher score indicates varied and precise word choice; a lower score points to limited vocabulary and inaccuracies.",
|
||||||
|
"Task Achievement":
|
||||||
|
"Evaluates how well the task requirements are fulfilled. A higher score means all parts of the task are addressed thoroughly; a lower score shows incomplete or inadequate task response.",
|
||||||
|
"Coherence and Cohesion":
|
||||||
|
"Measures logical organization and flow of writing. A higher score reflects well-structured and connected ideas; a lower score indicates disorganized writing and poor linkage between ideas.",
|
||||||
|
"Grammatical Range and Accuracy":
|
||||||
|
"Looks at the range and precision of grammatical structures. A higher score shows varied and accurate grammar use; a lower score suggests frequent errors and limited range.",
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
onBack({
|
||||||
|
exercise: id,
|
||||||
|
solutions: userSolutions,
|
||||||
|
score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() =>
|
||||||
|
onNext({
|
||||||
|
exercise: id,
|
||||||
|
solutions: userSolutions,
|
||||||
|
score: {
|
||||||
|
total: 100,
|
||||||
|
missing: 0,
|
||||||
|
correct: userSolutions[0]?.evaluation ? writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
||||||
|
},
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{attachment && (
|
{attachment && (
|
||||||
<Transition show={isModalOpen} as={Fragment}>
|
<Transition show={isModalOpen} as={Fragment}>
|
||||||
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
||||||
@@ -117,15 +168,37 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<div className="flex gap-4 px-1">
|
<div className="flex gap-4 px-1">
|
||||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key, index) => {
|
||||||
<div className="bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2" key={key}>
|
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
||||||
{key}: Level {userSolutions[0].evaluation!.task_response[key]}
|
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||||
</div>
|
|
||||||
))}
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
|
||||||
|
index === 0 && "tooltip-right",
|
||||||
|
)}
|
||||||
|
key={key}
|
||||||
|
data-tip={tooltips[key] || "No additional information available"}>
|
||||||
|
{key}: Level {grade}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{userSolutions[0].evaluation && userSolutions[0].evaluation.perfect_answer ? (
|
{userSolutions[0].evaluation && userSolutions[0].evaluation.perfect_answer ? (
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
|
||||||
|
<Tab
|
||||||
|
className={({selected}) =>
|
||||||
|
clsx(
|
||||||
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
||||||
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
General Feedback
|
||||||
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
className={({selected}) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
@@ -148,16 +221,60 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
}>
|
}>
|
||||||
Recommended Answer
|
Recommended Answer
|
||||||
</Tab>
|
</Tab>
|
||||||
|
{aiEval && user?.type !== "student" && (
|
||||||
|
<Tab
|
||||||
|
className={({selected}) =>
|
||||||
|
clsx(
|
||||||
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
||||||
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
AI Use
|
||||||
|
</Tab>
|
||||||
|
)}
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels>
|
<Tab.Panels>
|
||||||
|
{/* Global */}
|
||||||
|
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
||||||
|
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
||||||
|
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 w-fit",
|
||||||
|
)}
|
||||||
|
key={key}>
|
||||||
|
{key}: Level {grade}
|
||||||
|
</div>
|
||||||
|
{typeof taskResponse !== "number" && (
|
||||||
|
<span className="px-2 py-2">{taskResponse.comment}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Tab.Panel>
|
||||||
|
{/* Evaluation */}
|
||||||
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
|
{/* Recommended Answer */}
|
||||||
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||||
{userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n").replaceAll("\\n", "\n")}
|
{userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n").replaceAll("\\n", "\n")}
|
||||||
</span>
|
</span>
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
|
{aiEval && user?.type !== "student" && (
|
||||||
|
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<AIDetection {...aiEval} />
|
||||||
|
</Tab.Panel>
|
||||||
|
)}
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
) : (
|
) : (
|
||||||
@@ -203,6 +320,6 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import Writing from "./Writing";
|
|||||||
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
|
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
|
||||||
|
|
||||||
export interface CommonProps {
|
export interface CommonProps {
|
||||||
updateIndex?: (internalIndex: number) => void;
|
|
||||||
onNext: (userSolutions: UserSolution) => void;
|
onNext: (userSolutions: UserSolution) => void;
|
||||||
onBack: (userSolutions: UserSolution) => void;
|
onBack: (userSolutions: UserSolution) => void;
|
||||||
}
|
}
|
||||||
@@ -36,15 +35,7 @@ export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: (
|
|||||||
case "matchSentences":
|
case "matchSentences":
|
||||||
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "multipleChoice":
|
case "multipleChoice":
|
||||||
return (
|
return <MultipleChoice key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
<MultipleChoice
|
|
||||||
key={exercise.id}
|
|
||||||
{...(exercise as MultipleChoiceExercise)}
|
|
||||||
updateIndex={updateIndex}
|
|
||||||
onNext={onNext}
|
|
||||||
onBack={onBack}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "writeBlanks":
|
case "writeBlanks":
|
||||||
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "writing":
|
case "writing":
|
||||||
|
|||||||
91
src/components/TrainingContent/Exercise.tsx
Normal file
91
src/components/TrainingContent/Exercise.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import React, { useState, useCallback } from "react";
|
||||||
|
import ExerciseWalkthrough from "@/training/ExerciseWalkthrough";
|
||||||
|
import { ITrainingTip, WalkthroughConfigs } from "./TrainingInterfaces";
|
||||||
|
|
||||||
|
|
||||||
|
// This wrapper is just to test new exercises from the handbook, will be removed when all the tips and exercises are in firestore
|
||||||
|
const TrainingExercise: React.FC<ITrainingTip> = (trainingTip: ITrainingTip) => {
|
||||||
|
const leftText = "<div class=\"container mx-auto px-4 overflow-x-auto\"><table class=\"min-w-full bg-white border border-gray-300\"><thead><tr class=\"bg-gray-100\"><th class=\"py-2 px-4 border-b font-semibold text-left\">Category</th><th class=\"py-2 px-4 border-b font-semibold text-left\">Option A</th><th class=\"py-2 px-4 border-b font-semibold text-left\">Option B</th></tr></thead><tbody><tr><td class=\"py-2 px-4 border-b font-medium\">Self</td><td class=\"py-2 px-4 border-b\">You need to take care of yourself and connect with the people around you.</td><td class=\"py-2 px-4 border-b\">Focus on your interests and talents and meet people who are like you.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Home</td><td class=\"py-2 px-4 border-b\">It's a good idea to paint your living room yellow.</td><td class=\"py-2 px-4 border-b\">You should arrange your home so that it makes you feel happy.</td></tr><tr><td class=\"py-2 px-4 border-b font-medium\">Financial Life</td><td class=\"py-2 px-4 border-b\">You can be happy if you have enough money, but don't want money too much.</td><td class=\"py-2 px-4 border-b\">If you waste money on things you don't need, you won't have enough money for things that you do need.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Social Life</td><td class=\"py-2 px-4 border-b\">A good group of friends can increase your happiness.</td><td class=\"py-2 px-4 border-b\">Researchers say that a happy friend can increase our mood by nine percent.</td></tr><tr><td class=\"py-2 px-4 border-b font-medium\">Workplace</td><td class=\"py-2 px-4 border-b\">You spend a lot of time at work, so you should like your workplace.</td><td class=\"py-2 px-4 border-b\">Your boss needs to be someone you enjoy working for.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Community</td><td class=\"py-2 px-4 border-b\">The place where you live is more important for happiness than anything else.</td><td class=\"py-2 px-4 border-b\">Live around people who have the same amount of money as you do.</td></tr></tbody></table></div>";
|
||||||
|
const tip = {
|
||||||
|
category: "Strategy",
|
||||||
|
body: "<p>Look for <b>clues to the main idea</b> in the first (and sometimes second) sentence of a paragraph.</p>"
|
||||||
|
}
|
||||||
|
const question = "<div class=\"container mx-auto px-4 py-8\"><h2 class=\"text-2xl font-bold mb-4\">Identifying Main Ideas</h2><p class=\"text-lg leading-relaxed mb-6\">Read the statements below. Circle the main idea in each pair of statements (a or b).</p></div>";
|
||||||
|
const rightTextData: WalkthroughConfigs[] = [
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-blue-100 p-4 rounded-lg mb-4'><h2 class='text-xl font-bold mb-2'>Identifying Main Ideas</h2><p class='text-gray-700 leading-relaxed'>Let's analyze each pair of statements to determine which one represents the main idea. We'll focus on which statement is more general and encompasses the overall concept.</p></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 5000,
|
||||||
|
"highlight": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-green-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>1. Self</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You need to take care of yourself and connect with the people around you.</b></p><p class='mt-2'>This statement is more comprehensive, covering both self-care and social connections. Option B is more specific and could be considered a subset of A.</p></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 8000,
|
||||||
|
"highlight": ["You need to take care of yourself and connect with the people around you."]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-yellow-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>2. Home</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>B. You should arrange your home so that it makes you feel happy.</b></p><p class='mt-2'>This statement is more general and applies to the entire home. Option A is a specific example that could fall under this broader concept.</p></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 8000,
|
||||||
|
"highlight": ["You should arrange your home so that it makes you feel happy."]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-pink-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>3. Financial Life</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You can be happy if you have enough money, but don't want money too much.</b></p><p class='mt-2'>This statement provides a balanced view of money's role in happiness. Option B is more specific and could be seen as a consequence of wanting money too much.</p></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 8000,
|
||||||
|
"highlight": ["You can be happy if you have enough money, but don't want money too much."]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-purple-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>4. Social Life</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. A good group of friends can increase your happiness.</b></p><p class='mt-2'>This statement is more general about the impact of friendships. Option B provides a specific statistic that supports this main idea.</p></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 8000,
|
||||||
|
"highlight": ["A good group of friends can increase your happiness."]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-indigo-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>5. Workplace</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You spend a lot of time at work, so you should like your workplace.</b></p><p class='mt-2'>This statement covers the overall importance of workplace satisfaction. Option B focuses on one specific aspect (the boss) and is less comprehensive.</p></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 8000,
|
||||||
|
"highlight": ["You spend a lot of time at work, so you should like your workplace."]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-red-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>6. Community</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. The place where you live is more important for happiness than anything else.</b></p><p class='mt-2'>While this statement might be debatable, it's more general and encompasses the overall importance of community. Option B is a specific suggestion about community demographics.</p></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 8000,
|
||||||
|
"highlight": ["The place where you live is more important for happiness than anything else."]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-orange-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>Key Strategy</h3><p class='text-gray-700 leading-relaxed'>When identifying main ideas:</p><ul class='list-disc pl-5 space-y-2'><li>Look for broader, more encompassing statements</li><li>Consider which statement other ideas could fall under</li><li>Identify which statement provides a general principle rather than a specific example</li></ul></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 8000,
|
||||||
|
"highlight": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-teal-50 p-4 rounded-lg'><h3 class='text-lg font-semibold mb-2'>Helpful Tip</h3><p class='text-gray-700 leading-relaxed'>Remember to look for clues to the main idea in the first (and sometimes second) sentence of a paragraph. In this exercise, we applied this concept to pairs of statements. This approach can help you quickly identify the central theme or main point in various types of text.</p></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 5000,
|
||||||
|
"highlight": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockTip: ITrainingTip = {
|
||||||
|
id: "some random id",
|
||||||
|
tipCategory: tip.category,
|
||||||
|
tipHtml: tip.body,
|
||||||
|
standalone: false,
|
||||||
|
exercise: {
|
||||||
|
question: question,
|
||||||
|
highlightable: leftText,
|
||||||
|
segments: rightTextData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col p-10">
|
||||||
|
<ExerciseWalkthrough {...trainingTip}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrainingExercise;
|
||||||
280
src/components/TrainingContent/ExerciseWalkthrough.tsx
Normal file
280
src/components/TrainingContent/ExerciseWalkthrough.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import React, {useState, useEffect, useRef, useCallback} from "react";
|
||||||
|
import {animated} from "@react-spring/web";
|
||||||
|
import {FaRegCirclePlay, FaRegCircleStop} from "react-icons/fa6";
|
||||||
|
import HighlightContent from "../HighlightContent";
|
||||||
|
import {ITrainingTip, SegmentRef, TimelineEvent} from "./TrainingInterfaces";
|
||||||
|
|
||||||
|
const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
||||||
|
const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState<number>(0);
|
||||||
|
const [walkthroughHtml, setWalkthroughHtml] = useState<string>("");
|
||||||
|
const [highlightedPhrases, setHighlightedPhrases] = useState<string[]>([]);
|
||||||
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
|
const timelineRef = useRef<TimelineEvent[]>([]);
|
||||||
|
const animationRef = useRef<number | null>(null);
|
||||||
|
const segmentsRef = useRef<SegmentRef[]>([]);
|
||||||
|
|
||||||
|
const toggleAutoPlay = useCallback(() => {
|
||||||
|
setIsAutoPlaying((prev) => {
|
||||||
|
if (!prev && currentTime === getMaxTime()) {
|
||||||
|
setCurrentTime(0);
|
||||||
|
}
|
||||||
|
return !prev;
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentTime]);
|
||||||
|
|
||||||
|
const handleAnimationComplete = useCallback(() => {
|
||||||
|
setIsAutoPlaying(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleResetAnimation = useCallback((newTime: number) => {
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getMaxTime = (): number => {
|
||||||
|
return (
|
||||||
|
tip.exercise?.segments.reduce((sum, segment) => sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0) ?? 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeline: TimelineEvent[] = [];
|
||||||
|
let currentTimePosition = 0;
|
||||||
|
segmentsRef.current = [];
|
||||||
|
|
||||||
|
tip.exercise?.segments.forEach((segment, index) => {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(segment.html, "text/html");
|
||||||
|
const words: string[] = [];
|
||||||
|
const walkTree = (node: Node) => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
words.push(...(node.textContent?.split(/\s+/).filter((word) => word.length > 0) || []));
|
||||||
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
Array.from(node.childNodes).forEach(walkTree);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walkTree(doc.body);
|
||||||
|
|
||||||
|
const textDuration = words.length * segment.wordDelay;
|
||||||
|
|
||||||
|
segmentsRef.current.push({
|
||||||
|
...segment,
|
||||||
|
words: words,
|
||||||
|
startTime: currentTimePosition,
|
||||||
|
endTime: currentTimePosition + textDuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
timeline.push({
|
||||||
|
type: "text",
|
||||||
|
start: currentTimePosition,
|
||||||
|
end: currentTimePosition + textDuration,
|
||||||
|
segmentIndex: index,
|
||||||
|
});
|
||||||
|
|
||||||
|
currentTimePosition += textDuration;
|
||||||
|
|
||||||
|
timeline.push({
|
||||||
|
type: "highlight",
|
||||||
|
start: currentTimePosition,
|
||||||
|
end: currentTimePosition + segment.holdDelay,
|
||||||
|
content: segment.highlight,
|
||||||
|
segmentIndex: index,
|
||||||
|
});
|
||||||
|
|
||||||
|
currentTimePosition += segment.holdDelay;
|
||||||
|
});
|
||||||
|
|
||||||
|
timelineRef.current = timeline;
|
||||||
|
}, [tip.exercise?.segments]);
|
||||||
|
|
||||||
|
const updateText = useCallback(() => {
|
||||||
|
const currentEvent = timelineRef.current.find((event) => currentTime >= event.start && currentTime < event.end);
|
||||||
|
|
||||||
|
if (currentEvent) {
|
||||||
|
if (currentEvent.type === "text") {
|
||||||
|
const segment = segmentsRef.current[currentEvent.segmentIndex];
|
||||||
|
const elapsedTime = currentTime - currentEvent.start;
|
||||||
|
const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length);
|
||||||
|
|
||||||
|
const previousSegmentsHtml = segmentsRef.current
|
||||||
|
.slice(0, currentEvent.segmentIndex)
|
||||||
|
.map((seg) => seg.html)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(segment.html, "text/html");
|
||||||
|
let wordCount = 0;
|
||||||
|
const walkTree = (node: Node, action: (node: Node) => void): boolean => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE && node.textContent) {
|
||||||
|
const words = node.textContent.split(/(\s+)/).filter((word) => word.length > 0);
|
||||||
|
if (wordCount + words.filter((w) => !/\s+/.test(w)).length <= wordsToShow) {
|
||||||
|
action(node.cloneNode(true));
|
||||||
|
wordCount += words.filter((w) => !/\s+/.test(w)).length;
|
||||||
|
} else {
|
||||||
|
const remainingWords = wordsToShow - wordCount;
|
||||||
|
const newTextContent = words.reduce(
|
||||||
|
(acc, word) => {
|
||||||
|
if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) {
|
||||||
|
acc.text += word;
|
||||||
|
acc.nonSpaceWords++;
|
||||||
|
} else if (/\s+/.test(word) || acc.nonSpaceWords < remainingWords) {
|
||||||
|
acc.text += word;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{text: "", nonSpaceWords: 0},
|
||||||
|
).text;
|
||||||
|
const newNode = node.cloneNode(false);
|
||||||
|
newNode.textContent = newTextContent;
|
||||||
|
action(newNode);
|
||||||
|
wordCount = wordsToShow;
|
||||||
|
}
|
||||||
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const clone = node.cloneNode(false);
|
||||||
|
action(clone);
|
||||||
|
Array.from(node.childNodes).some((child) => {
|
||||||
|
return walkTree(child, (childNode) => (clone as Node).appendChild(childNode));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return wordCount >= wordsToShow;
|
||||||
|
};
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
walkTree(doc.body, (node) => fragment.appendChild(node));
|
||||||
|
|
||||||
|
const serializer = new XMLSerializer();
|
||||||
|
const currentSegmentHtml = Array.from(fragment.childNodes)
|
||||||
|
.map((node) => serializer.serializeToString(node))
|
||||||
|
.join("");
|
||||||
|
const newHtml = previousSegmentsHtml + currentSegmentHtml;
|
||||||
|
|
||||||
|
setWalkthroughHtml(newHtml);
|
||||||
|
setHighlightedPhrases([]);
|
||||||
|
} else if (currentEvent.type === "highlight") {
|
||||||
|
const newHtml = segmentsRef.current
|
||||||
|
.slice(0, currentEvent.segmentIndex + 1)
|
||||||
|
.map((seg) => seg.html)
|
||||||
|
.join("");
|
||||||
|
setWalkthroughHtml(newHtml);
|
||||||
|
setHighlightedPhrases(currentEvent.content || []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [currentTime]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateText();
|
||||||
|
}, [currentTime, updateText]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAutoPlaying) {
|
||||||
|
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
|
||||||
|
if (lastEvent && currentTime >= lastEvent.end) {
|
||||||
|
setCurrentTime(0);
|
||||||
|
}
|
||||||
|
setIsPlaying(true);
|
||||||
|
} else {
|
||||||
|
setIsPlaying(false);
|
||||||
|
}
|
||||||
|
}, [isAutoPlaying, currentTime]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const animate = () => {
|
||||||
|
if (isPlaying) {
|
||||||
|
setCurrentTime((prevTime) => {
|
||||||
|
const newTime = prevTime + 50;
|
||||||
|
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
|
||||||
|
if (lastEvent && newTime >= lastEvent.end) {
|
||||||
|
setIsPlaying(false);
|
||||||
|
handleAnimationComplete();
|
||||||
|
return lastEvent.end;
|
||||||
|
}
|
||||||
|
return newTime;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationRef.current) {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isPlaying, handleAnimationComplete]);
|
||||||
|
|
||||||
|
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newTime = parseInt(e.target.value, 10);
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
handleResetAnimation(newTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSliderMouseDown = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSliderMouseUp = () => {
|
||||||
|
if (isAutoPlaying) {
|
||||||
|
setIsPlaying(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tip.standalone || !tip.exercise) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<h1 className="text-xl font-bold text-red-600">The exercise for this tip is not available yet!</h1>
|
||||||
|
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4 mt-10">
|
||||||
|
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
|
||||||
|
<div className="text-gray-700" dangerouslySetInnerHTML={{__html: tip.tipHtml}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4">
|
||||||
|
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
|
||||||
|
<div className="text-gray-700" dangerouslySetInnerHTML={{__html: tip.tipHtml}} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
<div className="flex flex-row items-center space-x-4 py-4">
|
||||||
|
<button
|
||||||
|
onClick={toggleAutoPlay}
|
||||||
|
className="p-2 bg-blue-500 text-white rounded-full transition-colors duration-200 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
|
||||||
|
aria-label={isAutoPlaying ? "Pause" : "Play"}>
|
||||||
|
{isAutoPlaying ? <FaRegCircleStop className="w-6 h-6" /> : <FaRegCirclePlay className="w-6 h-6" />}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={timelineRef.current.length > 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0}
|
||||||
|
value={currentTime}
|
||||||
|
onChange={handleSliderChange}
|
||||||
|
onMouseDown={handleSliderMouseDown}
|
||||||
|
onMouseUp={handleSliderMouseUp}
|
||||||
|
onTouchStart={handleSliderMouseDown}
|
||||||
|
onTouchEnd={handleSliderMouseUp}
|
||||||
|
className="flex-grow"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4">
|
||||||
|
<div className="flex-1 bg-white p-6 rounded-lg shadow">
|
||||||
|
{/*<h2 className="text-xl font-bold mb-4">Question</h2>*/}
|
||||||
|
<div className="mb-4" dangerouslySetInnerHTML={{__html: tip.exercise.question}} />
|
||||||
|
<HighlightContent html={tip.exercise.highlightable} highlightPhrases={highlightedPhrases} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="bg-gray-50 rounded-lg shadow">
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<animated.div dangerouslySetInnerHTML={{__html: walkthroughHtml}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExerciseWalkthrough;
|
||||||
57
src/components/TrainingContent/TrainingInterfaces.ts
Normal file
57
src/components/TrainingContent/TrainingInterfaces.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Stat } from "@/interfaces/user";
|
||||||
|
|
||||||
|
export interface ITrainingContent {
|
||||||
|
id: string;
|
||||||
|
created_at: number;
|
||||||
|
user: string;
|
||||||
|
exams: {
|
||||||
|
id: string;
|
||||||
|
date: number;
|
||||||
|
detailed_summary: string;
|
||||||
|
performance_comment: string;
|
||||||
|
score: number;
|
||||||
|
module: string;
|
||||||
|
stat_ids: string[];
|
||||||
|
stats?: Stat[];
|
||||||
|
}[];
|
||||||
|
tip_ids: string[];
|
||||||
|
tips?: ITrainingTip[];
|
||||||
|
weak_areas: {
|
||||||
|
area: string;
|
||||||
|
comment: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITrainingTip {
|
||||||
|
id: string;
|
||||||
|
tipCategory: string;
|
||||||
|
tipHtml: string;
|
||||||
|
standalone: boolean;
|
||||||
|
exercise?: {
|
||||||
|
question: string;
|
||||||
|
highlightable: string;
|
||||||
|
segments: WalkthroughConfigs[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalkthroughConfigs {
|
||||||
|
html: string;
|
||||||
|
wordDelay: number;
|
||||||
|
holdDelay: number;
|
||||||
|
highlight: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface TimelineEvent {
|
||||||
|
type: 'text' | 'highlight';
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
segmentIndex: number;
|
||||||
|
content?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SegmentRef extends WalkthroughConfigs {
|
||||||
|
words: string[];
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
}
|
||||||
91
src/components/TrainingContent/TrainingScore.tsx
Normal file
91
src/components/TrainingContent/TrainingScore.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RiArrowRightUpLine, RiArrowLeftDownLine } from 'react-icons/ri';
|
||||||
|
import { FaChartLine } from 'react-icons/fa';
|
||||||
|
import { GiLightBulb } from 'react-icons/gi';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { ITrainingContent } from './TrainingInterfaces';
|
||||||
|
|
||||||
|
interface TrainingScoreProps {
|
||||||
|
trainingContent: ITrainingContent
|
||||||
|
gridView: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TrainingScore: React.FC<TrainingScoreProps> = ({
|
||||||
|
trainingContent,
|
||||||
|
gridView
|
||||||
|
}) => {
|
||||||
|
const scores = trainingContent.exams.map(exam => exam.score);
|
||||||
|
const highestScore = Math.max(...scores);
|
||||||
|
const lowestScore = Math.min(...scores);
|
||||||
|
let averageScore = scores.length > 0
|
||||||
|
? scores.reduce((sum, score) => sum + score, 0) / scores.length
|
||||||
|
: 0;
|
||||||
|
averageScore = Math.round(averageScore);
|
||||||
|
|
||||||
|
const containerClasses = clsx(
|
||||||
|
"flex flex-row mb-4",
|
||||||
|
gridView ? "gap-4 justify-between" : "gap-8"
|
||||||
|
);
|
||||||
|
|
||||||
|
const columnClasses = clsx(
|
||||||
|
"flex flex-col",
|
||||||
|
gridView ? "gap-4" : "gap-8"
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClasses}>
|
||||||
|
<div className={columnClasses}>
|
||||||
|
<div className="flex flex-row items-center gap-4">
|
||||||
|
<div className="flex w-14 h-14 bg-[#F5F5F5] items-center justify-center rounded-xl border border-[#DBDBDB]">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M11.7083 3.16669C11.4166 3.16669 11.1701 3.06599 10.9687 2.8646C10.7673 2.66321 10.6666 2.41669 10.6666 2.12502C10.6666 1.83335 10.7673 1.58683 10.9687 1.38544C11.1701 1.18405 11.4166 1.08335 11.7083 1.08335C12 1.08335 12.2465 1.18405 12.4479 1.38544C12.6493 1.58683 12.75 1.83335 12.75 2.12502C12.75 2.41669 12.6493 2.66321 12.4479 2.8646C12.2465 3.06599 12 3.16669 11.7083 3.16669ZM11.7083 16.9167C11.4166 16.9167 11.1701 16.816 10.9687 16.6146C10.7673 16.4132 10.6666 16.1667 10.6666 15.875C10.6666 15.5834 10.7673 15.3368 10.9687 15.1354C11.1701 14.934 11.4166 14.8334 11.7083 14.8334C12 14.8334 12.2465 14.934 12.4479 15.1354C12.6493 15.3368 12.75 15.5834 12.75 15.875C12.75 16.1667 12.6493 16.4132 12.4479 16.6146C12.2465 16.816 12 16.9167 11.7083 16.9167ZM15.0416 6.08335C14.75 6.08335 14.5034 5.98266 14.302 5.78127C14.1007 5.57988 14 5.33335 14 5.04169C14 4.75002 14.1007 4.50349 14.302 4.3021C14.5034 4.10071 14.75 4.00002 15.0416 4.00002C15.3333 4.00002 15.5798 4.10071 15.7812 4.3021C15.9826 4.50349 16.0833 4.75002 16.0833 5.04169C16.0833 5.33335 15.9826 5.57988 15.7812 5.78127C15.5798 5.98266 15.3333 6.08335 15.0416 6.08335ZM15.0416 14C14.75 14 14.5034 13.8993 14.302 13.6979C14.1007 13.4965 14 13.25 14 12.9584C14 12.6667 14.1007 12.4202 14.302 12.2188C14.5034 12.0174 14.75 11.9167 15.0416 11.9167C15.3333 11.9167 15.5798 12.0174 15.7812 12.2188C15.9826 12.4202 16.0833 12.6667 16.0833 12.9584C16.0833 13.25 15.9826 13.4965 15.7812 13.6979C15.5798 13.8993 15.3333 14 15.0416 14ZM16.2916 10.0417C16 10.0417 15.7534 9.94099 15.552 9.7396C15.3507 9.53821 15.25 9.29169 15.25 9.00002C15.25 8.70835 15.3507 8.46183 15.552 8.26044C15.7534 8.05905 16 7.95835 16.2916 7.95835C16.5833 7.95835 16.8298 8.05905 17.0312 8.26044C17.2326 8.46183 17.3333 8.70835 17.3333 9.00002C17.3333 9.29169 17.2326 9.53821 17.0312 9.7396C16.8298 9.94099 16.5833 10.0417 16.2916 10.0417ZM8.99996 17.3334C7.84718 17.3334 6.76385 17.1146 5.74996 16.6771C4.73607 16.2396 3.85413 15.6459 3.10413 14.8959C2.35413 14.1459 1.76038 13.2639 1.32288 12.25C0.885376 11.2361 0.666626 10.1528 0.666626 9.00002C0.666626 7.84724 0.885376 6.76391 1.32288 5.75002C1.76038 4.73613 2.35413 3.85419 3.10413 3.10419C3.85413 2.35419 4.73607 1.76044 5.74996 1.32294C6.76385 0.885437 7.84718 0.666687 8.99996 0.666687V2.33335C7.13885 2.33335 5.56246 2.97919 4.27079 4.27085C2.97913 5.56252 2.33329 7.13891 2.33329 9.00002C2.33329 10.8611 2.97913 12.4375 4.27079 13.7292C5.56246 15.0209 7.13885 15.6667 8.99996 15.6667V17.3334ZM8.99996 10.6667C8.54163 10.6667 8.14927 10.5035 7.82288 10.1771C7.49649 9.85071 7.33329 9.45835 7.33329 9.00002C7.33329 8.93058 7.33676 8.85766 7.34371 8.78127C7.35065 8.70488 7.36801 8.63196 7.39579 8.56252L5.66663 6.83335L6.83329 5.66669L8.56246 7.39585C8.61801 7.38196 8.76385 7.36113 8.99996 7.33335C9.45829 7.33335 9.85065 7.49655 10.177 7.82294C10.5034 8.14933 10.6666 8.54169 10.6666 9.00002C10.6666 9.45835 10.5034 9.85071 10.177 10.1771C9.85065 10.5035 9.45829 10.6667 8.99996 10.6667Z" fill="#40A1EA" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="font-bold">{trainingContent.exams.length}</p>
|
||||||
|
<p>Exams Selected</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-center gap-4">
|
||||||
|
<div className="flex w-14 h-14 bg-[#F5F5F5] items-center justify-center rounded-xl border border-[#DBDBDB]">
|
||||||
|
<RiArrowRightUpLine color={"#22E1B3"} size={gridView ? 28 : 26} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="font-bold">{highestScore}%</p>
|
||||||
|
<p>Highest Score</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={columnClasses}>
|
||||||
|
<div className="flex flex-row items-center gap-4">
|
||||||
|
<div className="flex w-14 h-14 bg-[#F5F5F5] items-center justify-center rounded-xl border border-[#DBDBDB]">
|
||||||
|
<FaChartLine color={"#40A1EA"} size={gridView ? 24 : 26} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="font-bold">{averageScore}%</p>
|
||||||
|
<p>Average Score</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-center gap-4">
|
||||||
|
<div className="flex w-14 h-14 bg-[#F5F5F5] items-center justify-center rounded-xl border border-[#DBDBDB]">
|
||||||
|
<RiArrowLeftDownLine color={"#E13922"} size={gridView ? 28 : 26} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="font-bold">{lowestScore}%</p>
|
||||||
|
<p>Lowest Score</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{gridView && (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 -lg:hidden">
|
||||||
|
<div className="flex w-14 h-14 bg-[#F5F5F5] items-center justify-center rounded-xl border border-[#DBDBDB]">
|
||||||
|
<GiLightBulb color={"#FFCC00"} size={28} />
|
||||||
|
</div>
|
||||||
|
<p><span className="font-bold">{trainingContent.tip_ids.length}</span> Tips</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrainingScore;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import useStats from "@/hooks/useStats";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import {EMPLOYMENT_STATUS, User} from "@/interfaces/user";
|
import {CorporateInformation, CorporateUser, EMPLOYMENT_STATUS, User, Type, Stat, Gender} from "@/interfaces/user";
|
||||||
import {groupBySession, averageScore} from "@/utils/stats";
|
import {groupBySession, averageScore} from "@/utils/stats";
|
||||||
import {RadioGroup} from "@headlessui/react";
|
import {RadioGroup} from "@headlessui/react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -8,7 +8,7 @@ import moment from "moment";
|
|||||||
import {Divider} from "primereact/divider";
|
import {Divider} from "primereact/divider";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import ReactDatePicker from "react-datepicker";
|
import ReactDatePicker from "react-datepicker";
|
||||||
import {BsFileEarmarkText, BsPencil, BsStar} from "react-icons/bs";
|
import {BsFileEarmarkText, BsPencil, BsPerson, BsPersonAdd, BsStar} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import Button from "./Low/Button";
|
import Button from "./Low/Button";
|
||||||
import Checkbox from "./Low/Checkbox";
|
import Checkbox from "./Low/Checkbox";
|
||||||
@@ -19,6 +19,11 @@ import Select from "react-select";
|
|||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
import {CURRENCIES} from "@/resources/paypal";
|
import {CURRENCIES} from "@/resources/paypal";
|
||||||
|
import useCodes from "@/hooks/useCodes";
|
||||||
|
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
||||||
|
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||||
|
import {PermissionType} from "@/interfaces/permissions";
|
||||||
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
|
||||||
const expirationDateColor = (date: Date) => {
|
const expirationDateColor = (date: Date) => {
|
||||||
const momentDate = moment(date);
|
const momentDate = moment(date);
|
||||||
@@ -36,7 +41,11 @@ interface Props {
|
|||||||
onViewStudents?: () => void;
|
onViewStudents?: () => void;
|
||||||
onViewTeachers?: () => void;
|
onViewTeachers?: () => void;
|
||||||
onViewCorporate?: () => void;
|
onViewCorporate?: () => void;
|
||||||
|
maxUserAmount?: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
disabledFields?: {
|
||||||
|
countryManager?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const USER_STATUS_OPTIONS = [
|
const USER_STATUS_OPTIONS = [
|
||||||
@@ -59,34 +68,67 @@ const USER_TYPE_OPTIONS = Object.keys(USER_TYPE_LABELS).map((type) => ({
|
|||||||
label: USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS],
|
label: USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const CURRENCIES_OPTIONS = CURRENCIES.map(({label, currency}) => ({value: currency, label}));
|
const CURRENCIES_OPTIONS = CURRENCIES.map(({label, currency}) => ({
|
||||||
|
value: currency,
|
||||||
|
label,
|
||||||
|
}));
|
||||||
|
|
||||||
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers, onViewCorporate, disabled = false}: Props) => {
|
const UserCard = ({
|
||||||
|
user,
|
||||||
|
loggedInUser,
|
||||||
|
maxUserAmount,
|
||||||
|
onClose,
|
||||||
|
onViewStudents,
|
||||||
|
onViewTeachers,
|
||||||
|
onViewCorporate,
|
||||||
|
disabled = false,
|
||||||
|
disabledFields = {},
|
||||||
|
}: Props) => {
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
|
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
|
||||||
const [type, setType] = useState(user.type);
|
const [type, setType] = useState(user.type);
|
||||||
const [status, setStatus] = useState(user.status);
|
const [status, setStatus] = useState(user.status);
|
||||||
const [referralAgentLabel, setReferralAgentLabel] = useState<string>();
|
const [referralAgentLabel, setReferralAgentLabel] = useState<string>();
|
||||||
const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
|
const [position, setPosition] = useState<string | undefined>(
|
||||||
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
|
user.type === "corporate" || user.type === "mastercorporate" ? user.demographicInformation?.position : undefined,
|
||||||
|
);
|
||||||
|
const [studentID, setStudentID] = useState<string | undefined>(user.type === "student" ? user.studentID : undefined);
|
||||||
|
const [name, setName] = useState<string>(user.name);
|
||||||
|
const [phone, setPhone] = useState<string | undefined>(user.demographicInformation?.phone);
|
||||||
|
const [gender, setGender] = useState<Gender | undefined>(user.demographicInformation?.gender);
|
||||||
|
|
||||||
const [referralAgent, setReferralAgent] = useState(user.type === "corporate" ? user.corporateInformation?.referralAgent : undefined);
|
const [referralAgent, setReferralAgent] = useState(
|
||||||
|
user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation?.referralAgent : undefined,
|
||||||
|
);
|
||||||
const [companyName, setCompanyName] = useState(
|
const [companyName, setCompanyName] = useState(
|
||||||
user.type === "corporate"
|
user.type === "corporate" || user.type === "mastercorporate"
|
||||||
? user.corporateInformation?.companyInformation.name
|
? user.corporateInformation?.companyInformation.name
|
||||||
: user.type === "agent"
|
: user.type === "agent"
|
||||||
? user.agentInformation?.companyName
|
? user.agentInformation?.companyName
|
||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
|
const [arabName, setArabName] = useState(user.type === "agent" ? user.agentInformation?.companyArabName : undefined);
|
||||||
const [commercialRegistration, setCommercialRegistration] = useState(
|
const [commercialRegistration, setCommercialRegistration] = useState(
|
||||||
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
|
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
|
||||||
);
|
);
|
||||||
const [userAmount, setUserAmount] = useState(user.type === "corporate" ? user.corporateInformation?.companyInformation.userAmount : undefined);
|
const [userAmount, setUserAmount] = useState(
|
||||||
const [paymentValue, setPaymentValue] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.value : undefined);
|
user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation?.companyInformation.userAmount : undefined,
|
||||||
const [paymentCurrency, setPaymentCurrency] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.currency : "EUR");
|
);
|
||||||
const [monthlyDuration, setMonthlyDuration] = useState(user.type === "corporate" ? user.corporateInformation?.monthlyDuration : undefined);
|
const [paymentValue, setPaymentValue] = useState(
|
||||||
const [commissionValue, setCommission] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.commission : undefined);
|
user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation?.payment?.value : undefined,
|
||||||
const {stats} = useStats(user.id);
|
);
|
||||||
|
const [paymentCurrency, setPaymentCurrency] = useState(
|
||||||
|
user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation?.payment?.currency : "EUR",
|
||||||
|
);
|
||||||
|
const [monthlyDuration, setMonthlyDuration] = useState(
|
||||||
|
user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation?.monthlyDuration : undefined,
|
||||||
|
);
|
||||||
|
const [commissionValue, setCommission] = useState(
|
||||||
|
user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation?.payment?.commission : undefined,
|
||||||
|
);
|
||||||
|
const {data: stats} = useFilterRecordsByUser<Stat[]>(user.id);
|
||||||
const {users} = useUsers();
|
const {users} = useUsers();
|
||||||
|
const {codes} = useCodes(user.id);
|
||||||
|
const {permissions} = usePermissions(loggedInUser.id);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (users && users.length > 0) {
|
if (users && users.length > 0) {
|
||||||
@@ -101,25 +143,37 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
}, [users, referralAgent]);
|
}, [users, referralAgent]);
|
||||||
|
|
||||||
const updateUser = () => {
|
const updateUser = () => {
|
||||||
if (user.type === "corporate" && (!paymentValue || paymentValue < 0))
|
if (
|
||||||
|
(user.type === "corporate" || user.type === "mastercorporate") &&
|
||||||
|
(!paymentValue || paymentValue < 0) &&
|
||||||
|
["admin", "developer"].includes(loggedInUser.type)
|
||||||
|
)
|
||||||
return toast.error("Please set a price for the user's package before updating!");
|
return toast.error("Please set a price for the user's package before updating!");
|
||||||
|
|
||||||
if (!confirm(`Are you sure you want to update ${user.name}'s account?`)) return;
|
if (!confirm(`Are you sure you want to update ${user.name}'s account?`)) return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {
|
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {
|
||||||
...user,
|
...user,
|
||||||
subscriptionExpirationDate: expiryDate,
|
subscriptionExpirationDate: expiryDate,
|
||||||
|
studentID,
|
||||||
type,
|
type,
|
||||||
status,
|
status,
|
||||||
|
name,
|
||||||
|
demographicInformation: {
|
||||||
|
...(!!user.demographicInformation ? user.demographicInformation : {}),
|
||||||
|
phone,
|
||||||
|
},
|
||||||
agentInformation:
|
agentInformation:
|
||||||
type === "agent"
|
type === "agent"
|
||||||
? {
|
? {
|
||||||
name: companyName,
|
companyName,
|
||||||
commercialRegistration,
|
commercialRegistration,
|
||||||
|
arabName,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
corporateInformation:
|
corporateInformation:
|
||||||
type === "corporate"
|
type === "corporate" || type === "mastercorporate"
|
||||||
? {
|
? {
|
||||||
referralAgent,
|
referralAgent,
|
||||||
monthlyDuration,
|
monthlyDuration,
|
||||||
@@ -144,38 +198,70 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const generalProfileItems = [
|
||||||
|
{
|
||||||
|
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||||
|
value: Object.keys(groupBySession(stats)).length,
|
||||||
|
label: "Exams",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||||
|
value: stats.length,
|
||||||
|
label: "Modules",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||||
|
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
|
||||||
|
label: "Average Score",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const corporateProfileItems =
|
||||||
|
user.type === "corporate" || user.type === "mastercorporate"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
icon: <BsPerson className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||||
|
value: codes.length,
|
||||||
|
label: "Users Used",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <BsPersonAdd className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||||
|
value: user.corporateInformation?.companyInformation?.userAmount,
|
||||||
|
label: "Number of Users",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const updateUserPermission = PERMISSIONS.updateUser[user.type] as {
|
||||||
|
list: Type[];
|
||||||
|
perm: PermissionType;
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ProfileSummary
|
<ProfileSummary
|
||||||
user={user}
|
user={user}
|
||||||
items={[
|
items={user.type === "corporate" || user.type === "mastercorporate" ? corporateProfileItems : generalProfileItems}
|
||||||
{
|
|
||||||
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
|
||||||
value: Object.keys(groupBySession(stats)).length,
|
|
||||||
label: "Exams",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
|
||||||
value: stats.length,
|
|
||||||
label: "Exercises",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
|
||||||
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
|
|
||||||
label: "Average Score",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{user.type === "agent" && (
|
{user.type === "agent" && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 w-full">
|
||||||
<Input
|
<Input
|
||||||
label="Corporate Name"
|
label="Company Name (Arabic)"
|
||||||
|
type="text"
|
||||||
|
name="arabName"
|
||||||
|
onChange={setArabName}
|
||||||
|
placeholder="Enter their company's name in arabic"
|
||||||
|
defaultValue={arabName}
|
||||||
|
required
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Company Name (English)"
|
||||||
type="text"
|
type="text"
|
||||||
name="companyName"
|
name="companyName"
|
||||||
onChange={setCompanyName}
|
onChange={setCompanyName}
|
||||||
placeholder="Enter corporate name"
|
placeholder="Enter their company's name in english"
|
||||||
defaultValue={companyName}
|
defaultValue={companyName}
|
||||||
required
|
required
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -194,7 +280,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
<Divider className="w-full !m-0" />
|
<Divider className="w-full !m-0" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{user.type === "corporate" && (
|
{(user.type === "corporate" || user.type === "mastercorporate") && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">
|
||||||
<Input
|
<Input
|
||||||
@@ -204,16 +290,31 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
onChange={setCompanyName}
|
onChange={setCompanyName}
|
||||||
placeholder="Enter corporate name"
|
placeholder="Enter corporate name"
|
||||||
defaultValue={companyName}
|
defaultValue={companyName}
|
||||||
disabled={disabled}
|
disabled={
|
||||||
|
disabled ||
|
||||||
|
checkAccess(
|
||||||
|
loggedInUser,
|
||||||
|
getTypesOfUser(
|
||||||
|
user.type === "mastercorporate" ? ["developer", "admin"] : ["developer", "admin", "mastercorporate"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Number of Users"
|
label="Number of Users"
|
||||||
type="number"
|
type="number"
|
||||||
name="userAmount"
|
name="userAmount"
|
||||||
|
max={maxUserAmount}
|
||||||
onChange={(e) => setUserAmount(e ? parseInt(e) : undefined)}
|
onChange={(e) => setUserAmount(e ? parseInt(e) : undefined)}
|
||||||
placeholder="Enter number of users"
|
placeholder="Enter number of users"
|
||||||
defaultValue={userAmount}
|
defaultValue={userAmount}
|
||||||
disabled={disabled}
|
disabled={
|
||||||
|
disabled ||
|
||||||
|
checkAccess(
|
||||||
|
loggedInUser,
|
||||||
|
getTypesOfUser(["developer", "admin", ...((user.type === "corporate" ? ["mastercorporate"] : []) as Type[])]),
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Monthly Duration"
|
label="Monthly Duration"
|
||||||
@@ -222,7 +323,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
onChange={(e) => setMonthlyDuration(e ? parseInt(e) : undefined)}
|
onChange={(e) => setMonthlyDuration(e ? parseInt(e) : undefined)}
|
||||||
placeholder="Enter monthly duration"
|
placeholder="Enter monthly duration"
|
||||||
defaultValue={monthlyDuration}
|
defaultValue={monthlyDuration}
|
||||||
disabled={disabled}
|
disabled={disabled || checkAccess(loggedInUser, getTypesOfUser(["developer", "admin"]))}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-3 w-full lg:col-span-3">
|
<div className="flex flex-col gap-3 w-full lg:col-span-3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Pricing</label>
|
<label className="font-normal text-base text-mti-gray-dim">Pricing</label>
|
||||||
@@ -233,7 +334,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
type="number"
|
type="number"
|
||||||
defaultValue={paymentValue || 0}
|
defaultValue={paymentValue || 0}
|
||||||
className="col-span-3"
|
className="col-span-3"
|
||||||
disabled={disabled}
|
disabled={disabled || checkAccess(loggedInUser, getTypesOfUser(["developer", "admin"]))}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -261,7 +362,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
color: state.isFocused ? "black" : styles.color,
|
color: state.isFocused ? "black" : styles.color,
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
isDisabled={disabled}
|
isDisabled={disabled || checkAccess(loggedInUser, getTypesOfUser(["developer", "admin"]))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -273,12 +374,17 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
<Select
|
<Select
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
|
"px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
|
||||||
!["developer", "admin"].includes(loggedInUser.type) &&
|
(checkAccess(loggedInUser, getTypesOfUser(["developer", "admin"])) || disabledFields.countryManager) &&
|
||||||
"!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
"!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
options={[
|
options={[
|
||||||
{value: "", label: "No referral"},
|
{value: "", label: "No referral"},
|
||||||
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})),
|
...users
|
||||||
|
.filter((u) => u.type === "agent")
|
||||||
|
.map((x) => ({
|
||||||
|
value: x.id,
|
||||||
|
label: `${x.name} - ${x.email}`,
|
||||||
|
})),
|
||||||
]}
|
]}
|
||||||
defaultValue={{
|
defaultValue={{
|
||||||
value: referralAgent,
|
value: referralAgent,
|
||||||
@@ -304,7 +410,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
// editing country manager should only be available for dev/admin
|
// editing country manager should only be available for dev/admin
|
||||||
isDisabled={!["developer", "admin"].includes(loggedInUser.type)}
|
isDisabled={checkAccess(loggedInUser, getTypesOfUser(["developer", "admin"])) || disabledFields.countryManager}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -335,10 +441,10 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
label="Name"
|
label="Name"
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
onChange={() => null}
|
onChange={setName}
|
||||||
placeholder="Enter your name"
|
placeholder="Enter your name"
|
||||||
defaultValue={user.name}
|
defaultValue={name}
|
||||||
disabled
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="E-mail Address"
|
label="E-mail Address"
|
||||||
@@ -360,28 +466,39 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
type="tel"
|
type="tel"
|
||||||
name="phone"
|
name="phone"
|
||||||
label="Phone number"
|
label="Phone number"
|
||||||
onChange={() => null}
|
onChange={setPhone}
|
||||||
placeholder="Enter phone number"
|
placeholder="Enter phone number"
|
||||||
defaultValue={user.demographicInformation?.phone}
|
defaultValue={phone}
|
||||||
disabled
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{user.type === "student" && (
|
{user.type === "student" && (
|
||||||
<Input
|
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||||
type="text"
|
<Input
|
||||||
name="passport_id"
|
type="text"
|
||||||
label="Passport/National ID"
|
name="passport_id"
|
||||||
onChange={() => null}
|
label="Passport/National ID"
|
||||||
placeholder="Enter National ID or Passport number"
|
onChange={() => null}
|
||||||
value={user.type === "student" ? user.demographicInformation?.passport_id : undefined}
|
placeholder="Enter National ID or Passport number"
|
||||||
disabled
|
value={user.type === "student" ? user.demographicInformation?.passport_id : undefined}
|
||||||
required
|
disabled
|
||||||
/>
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="studentID"
|
||||||
|
label="Student ID"
|
||||||
|
onChange={setStudentID}
|
||||||
|
placeholder="Enter Student ID"
|
||||||
|
disabled={!checkAccess(loggedInUser, getTypesOfUser(["teacher", "agent", "student"]), permissions, "editStudent")}
|
||||||
|
value={studentID}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||||
{user.type !== "corporate" && (
|
{user.type !== "corporate" && user.type !== "mastercorporate" && (
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Employment Status</label>
|
<label className="font-normal text-base text-mti-gray-dim">Employment Status</label>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
@@ -407,12 +524,12 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{user.type === "corporate" && (
|
{(user.type === "corporate" || user.type === "mastercorporate") && (
|
||||||
<Input
|
<Input
|
||||||
name="position"
|
name="position"
|
||||||
onChange={setPosition}
|
onChange={setPosition}
|
||||||
type="text"
|
type="text"
|
||||||
label="Position"
|
label="Department"
|
||||||
defaultValue={position}
|
defaultValue={position}
|
||||||
placeholder="CEO, Head of Marketing..."
|
placeholder="CEO, Head of Marketing..."
|
||||||
disabled
|
disabled
|
||||||
@@ -423,7 +540,8 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
<div className="relative flex flex-col gap-3 w-full">
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Gender</label>
|
<label className="font-normal text-base text-mti-gray-dim">Gender</label>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={user.demographicInformation?.gender}
|
value={gender}
|
||||||
|
onChange={(e) => setGender(e)}
|
||||||
className="flex flex-row gap-4 justify-between"
|
className="flex flex-row gap-4 justify-between"
|
||||||
disabled={disabled}>
|
disabled={disabled}>
|
||||||
<RadioGroup.Option value="male">
|
<RadioGroup.Option value="male">
|
||||||
@@ -476,7 +594,11 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
isChecked={!!expiryDate}
|
isChecked={!!expiryDate}
|
||||||
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : null)}
|
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : null)}
|
||||||
disabled={disabled}>
|
disabled={
|
||||||
|
disabled ||
|
||||||
|
(!["admin", "developer", "mastercorporate", "corporate"].includes(loggedInUser.type) &&
|
||||||
|
!!loggedInUser.subscriptionExpirationDate)
|
||||||
|
}>
|
||||||
Enabled
|
Enabled
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
@@ -515,7 +637,12 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(loggedInUser.type === "developer" || loggedInUser.type === "admin") && (
|
{checkAccess(
|
||||||
|
loggedInUser,
|
||||||
|
["developer", "admin", "corporate", "mastercorporate"],
|
||||||
|
permissions,
|
||||||
|
user.type === "teacher" ? "editTeacher" : user.type === "student" ? "editStudent" : undefined,
|
||||||
|
) && (
|
||||||
<>
|
<>
|
||||||
<Divider className="w-full !m-0" />
|
<Divider className="w-full !m-0" />
|
||||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||||
@@ -523,7 +650,10 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
<label className="font-normal text-base text-mti-gray-dim">Status</label>
|
<label className="font-normal text-base text-mti-gray-dim">Status</label>
|
||||||
<Select
|
<Select
|
||||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
options={USER_STATUS_OPTIONS}
|
options={USER_STATUS_OPTIONS.filter((x) => {
|
||||||
|
if (checkAccess(loggedInUser, ["admin", "developer"])) return true;
|
||||||
|
return x.value !== "paymentDue";
|
||||||
|
})}
|
||||||
menuPortalTarget={document?.body}
|
menuPortalTarget={document?.body}
|
||||||
value={USER_STATUS_OPTIONS.find((o) => o.value === status)}
|
value={USER_STATUS_OPTIONS.find((o) => o.value === status)}
|
||||||
onChange={(value) => setStatus(value?.value as typeof user.status)}
|
onChange={(value) => setStatus(value?.value as typeof user.status)}
|
||||||
@@ -551,7 +681,28 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
<label className="font-normal text-base text-mti-gray-dim">Type</label>
|
<label className="font-normal text-base text-mti-gray-dim">Type</label>
|
||||||
<Select
|
<Select
|
||||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
options={USER_TYPE_OPTIONS}
|
options={USER_TYPE_OPTIONS.filter((x) => {
|
||||||
|
if (x.value === "student")
|
||||||
|
return checkAccess(
|
||||||
|
loggedInUser,
|
||||||
|
["developer", "admin", "corporate", "mastercorporate"],
|
||||||
|
permissions,
|
||||||
|
"editStudent",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (x.value === "teacher")
|
||||||
|
return checkAccess(
|
||||||
|
loggedInUser,
|
||||||
|
["developer", "admin", "corporate", "mastercorporate"],
|
||||||
|
permissions,
|
||||||
|
"editTeacher",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (x.value === "corporate")
|
||||||
|
return checkAccess(loggedInUser, ["developer", "admin", "mastercorporate"], permissions, "editCorporate");
|
||||||
|
|
||||||
|
return checkAccess(loggedInUser, ["developer", "admin"]);
|
||||||
|
})}
|
||||||
menuPortalTarget={document?.body}
|
menuPortalTarget={document?.body}
|
||||||
value={USER_TYPE_OPTIONS.find((o) => o.value === type)}
|
value={USER_TYPE_OPTIONS.find((o) => o.value === type)}
|
||||||
onChange={(value) => setType(value?.value as typeof user.type)}
|
onChange={(value) => setType(value?.value as typeof user.type)}
|
||||||
@@ -602,7 +753,10 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
<Button className="w-full max-w-[200px]" variant="outline" onClick={onClose}>
|
<Button className="w-full max-w-[200px]" variant="outline" onClick={onClose}>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={disabled} onClick={updateUser} className="w-full max-w-[200px]">
|
<Button
|
||||||
|
disabled={disabled || !checkAccess(loggedInUser, updateUserPermission.list, permissions, updateUserPermission.perm)}
|
||||||
|
onClick={updateUser}
|
||||||
|
className="w-full max-w-[200px]">
|
||||||
Update
|
Update
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
31
src/components/ui/popover.tsx
Normal file
31
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
|
||||||
|
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border border-neutral-200 bg-white p-4 text-neutral-950 shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export type Error = "E001" | "E002";
|
export type Error = "E001" | "E002" | "E003";
|
||||||
export interface ErrorMessage {
|
export interface ErrorMessage {
|
||||||
error: Error;
|
error: Error;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -7,4 +7,5 @@ export interface ErrorMessage {
|
|||||||
export const errorMessages: {[key in Error]: string} = {
|
export const errorMessages: {[key in Error]: string} = {
|
||||||
E001: "Wrong password!",
|
E001: "Wrong password!",
|
||||||
E002: "Invalid e-mail",
|
E002: "Invalid e-mail",
|
||||||
|
E003: "E-mail already in use!",
|
||||||
};
|
};
|
||||||
|
|||||||
13
src/constants/staging.json
Normal file
13
src/constants/staging.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"type": "service_account",
|
||||||
|
"project_id": "encoach-staging",
|
||||||
|
"private_key_id": "5718a649419776df9637589f8696a258a6a70f6c",
|
||||||
|
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC2C6Es2gY8lLvH\ndVilNtRNm9glSaPXMNw2PzZZbSGuG1uGPFaCzlq1lOb2u17YfMG4GriKIMjIQKXF\nqdvxA8CAmAFRuDjUGmpbO/X1ZW7amOs5Bjed2BYmL01dEqzzwwh7rEfNDjeghRPx\n1uKzH8A6TLT5xq+74I5K1CIgiljBpZimsERu2SDawjkdtZfA7qoylA46Nq66LuwQ\nVyv9CK2SZNpBcT3sunCmRsrCzmSTzKdbcqRPdqUKgZOH/Rjp0sw9VuUgwoxdGZV3\n5SJjObo5ceZ1OSiJm7GwLzp7uq16sqycgSYwppNLI5OtzOfSuWbGD4+a044t2Mlq\n9PHXv7H/AgMBAAECggEAAfhKlFwq8MaL6PggRJq9HbaKgQ4fcOmCmy8AQmPNF1UM\nyVKSKGndjxUfPLCWsaaunUnjZlHoKkvndKXxDyttuVaBE9EiWEqNjRLZ3KpuJ9Jm\nH+CtLbmUCnISQb1n1AlvvZAwhLZbLBL/PhYyWiLapybZAdJAaOWLVKGgBD8gVRQW\nJFCqnszX1O2YlpWHutb979R4qoY/XAf94gyMkTpXZwuETvFqZbau2vxRZ8qARix3\nmic881PwiF6Cod8UPCS9yMK+Q+Se6SomwXU9PCmlummn9xmQBAxYy8gIAVs/J9Fg\n5SvhnImAPDd+zIzzw2cHCiruNWIhroMVZDZJgWdY1QKBgQDjTKKeFOur3ijJJL2/\nWg1SE2jLP0GpXzM5YMx6jdOCNDCzugPngRucRXiTkJ2FnUgyMcQyi6hyrbWXN/6z\nXhx5fwLB4tnTcqOMvNfcay5mDk3RW9ZZJxayB54Sf1Nm/4xiDBnGPT+iHQvK+/pT\nwScWznFkmk60E796o76OLn3PEwKBgQDNCC2uPq+uOcCopIO8HH88gqdxTvpbeHUU\nrdJOmr1VtGNuvay/mfpva9+VEtGbZTFzjhfvfCEIjpj3Llh8Flb9EYa6BmscBiyp\ngszEeFuB3zHndlSCZPnGJ7JiRAdPAEgG3Gl/r9th6PDaEMq0MFS5i7GGhPBIRYCG\nUtmY5eVy5QKBgH5Nuls/YsnJFD7ZNLscziQadvPhvZnhNbSfjmBXaP2EBMAKEFtX\nCcGndN4C0RVLFbAWqWAw7LR0xGA4FEcVd5snsZ+Nb98oZ6sv0H9B67F4J1O7xXsa\n1mitBPBgYjbsr9RXxwa6SB7MJx5vMGXUAeWRZ78wY6V7B76dOKkHOo+TAoGBAJf5\nBOsPueZZFm2qK58GPGVcrsI0+StNuPLP+H+dANQC9mTCIMaQWmm2Oq5jmYwmUKZH\nX4R6rH2MPOOSrbGkWWwRTpyaX1ARX49xzVefoqw8BOB8/Bz+vYjcKcPeitBK9Bhp\nzaUAc4s6PzRTl/xBirtRSQ/df8ECC0cFKBbF6PHlAoGAGqnlpo+k8vAtg6ulCuGu\nx2Y/c5UmvXGHk60pccnW3UtENSDnl99OgMfBz8/qLAMWs6DUQ/kvSlHQPmMBHRWZ\nNTr6ceGXyNs4KdYoj1K7AU3c0Lm0wyQ2giQMoOOUQAm98Xr8z5aiihj10hHPmzzL\n9kwpOmZpjNmC/ERD69imWhY=\n-----END PRIVATE KEY-----\n",
|
||||||
|
"client_email": "firebase-adminsdk-8rs9e@encoach-staging.iam.gserviceaccount.com",
|
||||||
|
"client_id": "108221424237414412378",
|
||||||
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||||
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
|
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||||
|
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-8rs9e%40encoach-staging.iam.gserviceaccount.com",
|
||||||
|
"universe_domain": "googleapis.com"
|
||||||
|
}
|
||||||
@@ -2,33 +2,85 @@ import {Type} from "@/interfaces/user";
|
|||||||
|
|
||||||
export const PERMISSIONS = {
|
export const PERMISSIONS = {
|
||||||
generateCode: {
|
generateCode: {
|
||||||
student: ["corporate", "developer", "admin"],
|
student: ["corporate", "developer", "admin", "mastercorporate"],
|
||||||
teacher: ["corporate", "developer", "admin"],
|
teacher: ["corporate", "developer", "admin", "mastercorporate"],
|
||||||
corporate: ["admin", "developer"],
|
corporate: ["admin", "developer"],
|
||||||
|
mastercorporate: ["admin", "developer"],
|
||||||
|
|
||||||
admin: ["developer", "admin"],
|
admin: ["developer", "admin"],
|
||||||
agent: ["developer", "admin"],
|
agent: ["developer", "admin"],
|
||||||
developer: ["developer"],
|
developer: ["developer"],
|
||||||
},
|
},
|
||||||
deleteUser: {
|
deleteUser: {
|
||||||
student: ["corporate", "developer", "admin"],
|
student: {
|
||||||
teacher: ["corporate", "developer", "admin"],
|
perm: "deleteStudent",
|
||||||
corporate: ["admin", "developer"],
|
list: ["corporate", "developer", "admin", "mastercorporate"],
|
||||||
admin: ["developer", "admin"],
|
},
|
||||||
agent: ["developer", "admin"],
|
teacher: {
|
||||||
developer: ["developer"],
|
perm: "deleteTeacher",
|
||||||
|
list: ["corporate", "developer", "admin", "mastercorporate"],
|
||||||
|
},
|
||||||
|
corporate: {
|
||||||
|
perm: "deleteCorporate",
|
||||||
|
list: ["admin", "developer"],
|
||||||
|
},
|
||||||
|
mastercorporate: {
|
||||||
|
perm: undefined,
|
||||||
|
list: ["admin", "developer"],
|
||||||
|
},
|
||||||
|
|
||||||
|
admin: {
|
||||||
|
perm: "deleteAdmin",
|
||||||
|
list: ["developer", "admin"],
|
||||||
|
},
|
||||||
|
agent: {
|
||||||
|
perm: "deleteCountryManager",
|
||||||
|
list: ["developer", "admin"],
|
||||||
|
},
|
||||||
|
developer: {
|
||||||
|
perm: undefined,
|
||||||
|
list: ["developer"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
updateUser: {
|
updateUser: {
|
||||||
student: ["developer", "admin"],
|
student: {
|
||||||
teacher: ["developer", "admin"],
|
perm: "editStudent",
|
||||||
corporate: ["admin", "developer"],
|
list: ["developer", "admin", "corporate", "mastercorporate", "teacher"],
|
||||||
admin: ["developer", "admin"],
|
},
|
||||||
agent: ["developer", "admin"],
|
teacher: {
|
||||||
developer: ["developer"],
|
perm: "editTeacher",
|
||||||
|
list: ["developer", "admin", "corporate", "mastercorporate"],
|
||||||
|
},
|
||||||
|
|
||||||
|
corporate: {
|
||||||
|
perm: "editCorporate",
|
||||||
|
list: ["developer", "admin", "mastercorporate"],
|
||||||
|
},
|
||||||
|
mastercorporate: {
|
||||||
|
perm: undefined,
|
||||||
|
list: ["admin", "developer"],
|
||||||
|
},
|
||||||
|
|
||||||
|
admin: {
|
||||||
|
perm: "editAdmin",
|
||||||
|
list: ["developer", "admin"],
|
||||||
|
},
|
||||||
|
|
||||||
|
agent: {
|
||||||
|
perm: "editCountryManager",
|
||||||
|
list: ["developer", "admin"],
|
||||||
|
},
|
||||||
|
developer: {
|
||||||
|
perm: undefined,
|
||||||
|
list: ["developer"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
updateExpiryDate: {
|
updateExpiryDate: {
|
||||||
student: ["developer", "admin"],
|
student: ["developer", "admin"],
|
||||||
teacher: ["developer", "admin"],
|
teacher: ["developer", "admin"],
|
||||||
corporate: ["admin", "developer"],
|
corporate: ["admin", "developer"],
|
||||||
|
mastercorporate: ["admin", "developer"],
|
||||||
|
|
||||||
admin: ["developer", "admin"],
|
admin: ["developer", "admin"],
|
||||||
agent: ["developer", "admin"],
|
agent: ["developer", "admin"],
|
||||||
developer: ["developer"],
|
developer: ["developer"],
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useStats from "@/hooks/useStats";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {User} from "@/interfaces/user";
|
import {Stat, User} from "@/interfaces/user";
|
||||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
import {dateSorter} from "@/utils";
|
import {dateSorter} from "@/utils";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
BsPencilSquare,
|
BsPencilSquare,
|
||||||
BsBank,
|
BsBank,
|
||||||
BsCurrencyDollar,
|
BsCurrencyDollar,
|
||||||
|
BsLayoutWtf,
|
||||||
|
BsLayoutSidebar,
|
||||||
} from "react-icons/bs";
|
} from "react-icons/bs";
|
||||||
import UserCard from "@/components/UserCard";
|
import UserCard from "@/components/UserCard";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
@@ -23,6 +25,7 @@ import IconCard from "./IconCard";
|
|||||||
import useFilterStore from "@/stores/listFilterStore";
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import usePaymentStatusUsers from "@/hooks/usePaymentStatusUsers";
|
import usePaymentStatusUsers from "@/hooks/usePaymentStatusUsers";
|
||||||
|
import CorporateStudentsLevels from "./CorporateStudentsLevels";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -33,23 +36,22 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
const {stats} = useStats(user.id);
|
const {data: stats} = useFilterRecordsByUser<Stat[]>(user.id);
|
||||||
const {users, reload} = useUsers();
|
const {users, reload} = useUsers();
|
||||||
const {groups} = useGroups();
|
const {groups} = useGroups({});
|
||||||
const {pending, done} = usePaymentStatusUsers();
|
const {pending, done} = usePaymentStatusUsers();
|
||||||
|
|
||||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowModal(!!selectedUser && page === "");
|
setShowModal(!!selectedUser && router.asPath === "/#");
|
||||||
}, [selectedUser, page]);
|
}, [selectedUser, router.asPath]);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
useEffect(reload, [page]);
|
useEffect(reload, [page]);
|
||||||
|
|
||||||
const inactiveCountryManagerFilter = (x: User) =>
|
const inactiveCountryManagerFilter = (x: User) => x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate);
|
||||||
x.type === "agent" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
|
||||||
|
|
||||||
const UserDisplay = (displayUser: User) => (
|
const UserDisplay = (displayUser: User) => (
|
||||||
<div
|
<div
|
||||||
@@ -69,170 +71,206 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
|
|
||||||
const StudentsList = () => {
|
const StudentsList = () => {
|
||||||
const filter = (x: User) =>
|
const filter = (x: User) =>
|
||||||
x.type === "student" &&
|
!!selectedUser
|
||||||
(!!selectedUser
|
|
||||||
? groups
|
? groups
|
||||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
.flatMap((g) => g.participants)
|
.flatMap((g) => g.participants)
|
||||||
.includes(x.id)
|
.includes(x.id)
|
||||||
: true);
|
: true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<UserList
|
||||||
<div className="flex flex-col gap-4">
|
user={user}
|
||||||
<div
|
type="student"
|
||||||
onClick={() => setPage("")}
|
filters={[filter]}
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
renderHeader={(total) => (
|
||||||
<BsArrowLeft className="text-xl" />
|
<div className="flex flex-col gap-4">
|
||||||
<span>Back</span>
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Students ({total})</h2>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
|
)}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<UserList user={user} filters={[filter]} />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TeachersList = () => {
|
const TeachersList = () => {
|
||||||
const filter = (x: User) =>
|
const filter = (x: User) =>
|
||||||
x.type === "teacher" &&
|
!!selectedUser
|
||||||
(!!selectedUser
|
|
||||||
? groups
|
? groups
|
||||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
.flatMap((g) => g.participants)
|
.flatMap((g) => g.participants)
|
||||||
.includes(x.id) || false
|
.includes(x.id) || false
|
||||||
: true);
|
: true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<UserList
|
||||||
<div className="flex flex-col gap-4">
|
user={user}
|
||||||
<div
|
type="teacher"
|
||||||
onClick={() => setPage("")}
|
filters={[filter]}
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
renderHeader={(total) => (
|
||||||
<BsArrowLeft className="text-xl" />
|
<div className="flex flex-col gap-4">
|
||||||
<span>Back</span>
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Teachers ({total})</h2>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2>
|
)}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<UserList user={user} filters={[filter]} />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AgentsList = () => {
|
const AgentsList = () => {
|
||||||
const filter = (x: User) => x.type === "agent";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<UserList
|
||||||
<div className="flex flex-col gap-4">
|
user={user}
|
||||||
<div
|
type="agent"
|
||||||
onClick={() => setPage("")}
|
renderHeader={(total) => (
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
<div className="flex flex-col gap-4">
|
||||||
<BsArrowLeft className="text-xl" />
|
<div
|
||||||
<span>Back</span>
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Country Managers ({total})</h2>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">Country Managers ({users.filter(filter).length})</h2>
|
)}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<UserList user={user} filters={[filter]} />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const CorporateList = () => (
|
const CorporateList = () => (
|
||||||
<>
|
<UserList
|
||||||
<div className="flex flex-col gap-4">
|
user={user}
|
||||||
<div
|
type="corporate"
|
||||||
onClick={() => setPage("")}
|
renderHeader={(total) => (
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold">Corporate ({users.filter((x) => x.type === "corporate").length})</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UserList user={user} filters={[(x) => x.type === "corporate"]} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const CorporatePaidStatusList = ({paid}: {paid: Boolean}) => {
|
|
||||||
const list = paid ? done : pending;
|
|
||||||
const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
onClick={() => router.push("/")}
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
<BsArrowLeft className="text-xl" />
|
<BsArrowLeft className="text-xl" />
|
||||||
<span>Back</span>
|
<span>Back</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">
|
<h2 className="text-2xl font-semibold">Corporate ({total})</h2>
|
||||||
{paid ? "Payment Done" : "Pending Payment"} ({list.length})
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<UserList user={user} filters={[filter]} />
|
)}
|
||||||
</>
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CorporatePaidStatusList = ({paid}: {paid: Boolean}) => {
|
||||||
|
const list = paid ? done : pending;
|
||||||
|
const filter = (x: User) => list.includes(x.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserList
|
||||||
|
user={user}
|
||||||
|
type="corporate"
|
||||||
|
filters={[filter]}
|
||||||
|
renderHeader={(total) => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">
|
||||||
|
{paid ? "Payment Done" : "Pending Payment"} ({total})
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const InactiveCountryManagerList = () => {
|
const InactiveCountryManagerList = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<UserList
|
||||||
<div className="flex flex-col gap-4">
|
user={user}
|
||||||
<div
|
type="agent"
|
||||||
onClick={() => setPage("")}
|
filters={[inactiveCountryManagerFilter]}
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
renderHeader={(total) => (
|
||||||
<BsArrowLeft className="text-xl" />
|
<div className="flex flex-col gap-4">
|
||||||
<span>Back</span>
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Inactive Country Managers ({total})</h2>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">Inactive Country Managers ({users.filter(inactiveCountryManagerFilter).length})</h2>
|
)}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<UserList user={user} filters={[inactiveCountryManagerFilter]} />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const InactiveStudentsList = () => {
|
const InactiveStudentsList = () => {
|
||||||
const filter = (x: User) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
const filter = (x: User) => x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<UserList
|
||||||
<div className="flex flex-col gap-4">
|
user={user}
|
||||||
<div
|
type="student"
|
||||||
onClick={() => setPage("")}
|
filters={[filter]}
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
renderHeader={(total) => (
|
||||||
<BsArrowLeft className="text-xl" />
|
<div className="flex flex-col gap-4">
|
||||||
<span>Back</span>
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Inactive Students ({total})</h2>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">Inactive Students ({users.filter(filter).length})</h2>
|
)}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<UserList user={user} filters={[filter]} />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const InactiveCorporateList = () => {
|
const InactiveCorporateList = () => {
|
||||||
const filter = (x: User) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
const filter = (x: User) => x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserList
|
||||||
|
user={user}
|
||||||
|
filters={[filter]}
|
||||||
|
type="corporate"
|
||||||
|
renderHeader={(total) => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Inactive Corporate ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CorporateStudentsLevelsHelper = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
onClick={() => router.push("/")}
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
<BsArrowLeft className="text-xl" />
|
<BsArrowLeft className="text-xl" />
|
||||||
<span>Back</span>
|
<span>Back</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">Inactive Corporate ({users.filter(filter).length})</h2>
|
<h2 className="text-2xl font-semibold">Corporate Students Levels</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<CorporateStudentsLevels />
|
||||||
<UserList user={user} filters={[filter]} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -244,28 +282,28 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
Icon={BsPersonFill}
|
Icon={BsPersonFill}
|
||||||
label="Students"
|
label="Students"
|
||||||
value={users.filter((x) => x.type === "student").length}
|
value={users.filter((x) => x.type === "student").length}
|
||||||
onClick={() => setPage("students")}
|
onClick={() => router.push("/#students")}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsPencilSquare}
|
Icon={BsPencilSquare}
|
||||||
label="Teachers"
|
label="Teachers"
|
||||||
value={users.filter((x) => x.type === "teacher").length}
|
value={users.filter((x) => x.type === "teacher").length}
|
||||||
onClick={() => setPage("teachers")}
|
onClick={() => router.push("/#teachers")}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsBank}
|
Icon={BsBank}
|
||||||
label="Corporate"
|
label="Corporate"
|
||||||
value={users.filter((x) => x.type === "corporate").length}
|
value={users.filter((x) => x.type === "corporate").length}
|
||||||
onClick={() => setPage("corporate")}
|
onClick={() => router.push("/#corporate")}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsBriefcaseFill}
|
Icon={BsBriefcaseFill}
|
||||||
label="Country Managers"
|
label="Country Managers"
|
||||||
value={users.filter((x) => x.type === "agent").length}
|
value={users.filter((x) => x.type === "agent").length}
|
||||||
onClick={() => setPage("agents")}
|
onClick={() => router.push("/#agents")}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
@@ -275,7 +313,7 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("inactiveStudents")}
|
onClick={() => router.push("/#inactiveStudents")}
|
||||||
Icon={BsPersonFill}
|
Icon={BsPersonFill}
|
||||||
label="Inactive Students"
|
label="Inactive Students"
|
||||||
value={
|
value={
|
||||||
@@ -285,14 +323,14 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
color="rose"
|
color="rose"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("inactiveCountryManagers")}
|
onClick={() => router.push("/#inactiveCountryManagers")}
|
||||||
Icon={BsBriefcaseFill}
|
Icon={BsBriefcaseFill}
|
||||||
label="Inactive Country Managers"
|
label="Inactive Country Managers"
|
||||||
value={users.filter(inactiveCountryManagerFilter).length}
|
value={users.filter(inactiveCountryManagerFilter).length}
|
||||||
color="rose"
|
color="rose"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("inactiveCorporate")}
|
onClick={() => router.push("/#inactiveCorporate")}
|
||||||
Icon={BsBank}
|
Icon={BsBank}
|
||||||
label="Inactive Corporate"
|
label="Inactive Corporate"
|
||||||
value={
|
value={
|
||||||
@@ -301,14 +339,32 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
}
|
}
|
||||||
color="rose"
|
color="rose"
|
||||||
/>
|
/>
|
||||||
<IconCard onClick={() => setPage("paymentdone")} Icon={BsCurrencyDollar} label="Payment Done" value={done.length} color="purple" />
|
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("paymentpending")}
|
onClick={() => router.push("/#paymentdone")}
|
||||||
|
Icon={BsCurrencyDollar}
|
||||||
|
label="Payment Done"
|
||||||
|
value={done.length}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
onClick={() => router.push("/#paymentpending")}
|
||||||
Icon={BsCurrencyDollar}
|
Icon={BsCurrencyDollar}
|
||||||
label="Pending Payment"
|
label="Pending Payment"
|
||||||
value={pending.length}
|
value={pending.length}
|
||||||
color="rose"
|
color="rose"
|
||||||
/>
|
/>
|
||||||
|
<IconCard
|
||||||
|
onClick={() => router.push("https://cms.encoach.com/admin")}
|
||||||
|
Icon={BsLayoutSidebar}
|
||||||
|
label="Content Management System (CMS)"
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
onClick={() => router.push("/#corporatestudentslevels")}
|
||||||
|
Icon={BsPersonFill}
|
||||||
|
label="Corporate Students Levels"
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
|
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
|
||||||
@@ -323,6 +379,19 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Latest teachers</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{users
|
||||||
|
.filter((x) => x.type === "teacher")
|
||||||
|
.sort((a, b) => {
|
||||||
|
return dateSorter(a, b, "desc", "registrationDate");
|
||||||
|
})
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Latest corporate</span>
|
<span className="p-4">Latest corporate</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
@@ -363,7 +432,7 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Country Manager expiring in 1 month</span>
|
<span className="p-4">Teachers expiring in 1 month</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{users
|
||||||
.filter(
|
.filter(
|
||||||
@@ -378,6 +447,22 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Country Manager expiring in 1 month</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{users
|
||||||
|
.filter(
|
||||||
|
(x) =>
|
||||||
|
x.type === "agent" &&
|
||||||
|
x.subscriptionExpirationDate &&
|
||||||
|
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
||||||
|
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
||||||
|
)
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Corporate expiring in 1 month</span>
|
<span className="p-4">Corporate expiring in 1 month</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
@@ -407,7 +492,7 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Expired Country Manager</span>
|
<span className="p-4">Expired Teachers</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{users
|
||||||
.filter(
|
.filter(
|
||||||
@@ -418,6 +503,18 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Expired Country Manager</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{users
|
||||||
|
.filter(
|
||||||
|
(x) => x.type === "agent" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
|
||||||
|
)
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Expired Corporate</span>
|
<span className="p-4">Expired Corporate</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
@@ -513,16 +610,17 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
</Modal>
|
</Modal>
|
||||||
{page === "students" && <StudentsList />}
|
{router.asPath === "/#students" && <StudentsList />}
|
||||||
{page === "teachers" && <TeachersList />}
|
{router.asPath === "/#teachers" && <TeachersList />}
|
||||||
{page === "corporate" && <CorporateList />}
|
{router.asPath === "/#corporate" && <CorporateList />}
|
||||||
{page === "agents" && <AgentsList />}
|
{router.asPath === "/#agents" && <AgentsList />}
|
||||||
{page === "inactiveStudents" && <InactiveStudentsList />}
|
{router.asPath === "/#inactiveStudents" && <InactiveStudentsList />}
|
||||||
{page === "inactiveCorporate" && <InactiveCorporateList />}
|
{router.asPath === "/#inactiveCorporate" && <InactiveCorporateList />}
|
||||||
{page === "inactiveCountryManagers" && <InactiveCountryManagerList />}
|
{router.asPath === "/#inactiveCountryManagers" && <InactiveCountryManagerList />}
|
||||||
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
|
{router.asPath === "/#paymentdone" && <CorporatePaidStatusList paid={true} />}
|
||||||
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
|
{router.asPath === "/#paymentpending" && <CorporatePaidStatusList paid={false} />}
|
||||||
{page === "" && <DefaultDashboard />}
|
{router.asPath === "/#corporatestudentslevels" && <CorporateStudentsLevelsHelper />}
|
||||||
|
{router.asPath === "/" && <DefaultDashboard />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useStats from "@/hooks/useStats";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import { User} from "@/interfaces/user";
|
import {Stat, User} from "@/interfaces/user";
|
||||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
import {dateSorter} from "@/utils";
|
import {dateSorter} from "@/utils";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
@@ -12,7 +12,7 @@ import UserCard from "@/components/UserCard";
|
|||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
|
||||||
import IconCard from "./IconCard";
|
import IconCard from "./IconCard";
|
||||||
import usePaymentStatusUsers from '@/hooks/usePaymentStatusUsers';
|
import usePaymentStatusUsers from "@/hooks/usePaymentStatusUsers";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -23,10 +23,9 @@ export default function AgentDashboard({user}: Props) {
|
|||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
const {stats} = useStats();
|
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
||||||
const {users, reload} = useUsers();
|
const {users, reload} = useUsers();
|
||||||
const {groups} = useGroups(user.id);
|
const {pending, done} = usePaymentStatusUsers();
|
||||||
const { pending, done } = usePaymentStatusUsers();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowModal(!!selectedUser && page === "");
|
setShowModal(!!selectedUser && page === "");
|
||||||
@@ -38,7 +37,7 @@ export default function AgentDashboard({user}: Props) {
|
|||||||
const inactiveReferredCorporateFilter = (x: User) =>
|
const inactiveReferredCorporateFilter = (x: User) =>
|
||||||
referredCorporateFilter(x) && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
referredCorporateFilter(x) && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
||||||
|
|
||||||
const UserDisplay = ({ displayUser, allowClick = true }: {displayUser: User, allowClick?: boolean}) => (
|
const UserDisplay = ({displayUser, allowClick = true}: {displayUser: User; allowClick?: boolean}) => (
|
||||||
<div
|
<div
|
||||||
onClick={() => allowClick && setSelectedUser(displayUser)}
|
onClick={() => allowClick && setSelectedUser(displayUser)}
|
||||||
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
||||||
@@ -56,37 +55,41 @@ export default function AgentDashboard({user}: Props) {
|
|||||||
|
|
||||||
const ReferredCorporateList = () => {
|
const ReferredCorporateList = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<UserList
|
||||||
<div className="flex flex-col gap-4">
|
user={user}
|
||||||
<div
|
filters={[referredCorporateFilter]}
|
||||||
onClick={() => setPage("")}
|
renderHeader={(total) => (
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
<div className="flex flex-col gap-4">
|
||||||
<BsArrowLeft className="text-xl" />
|
<div
|
||||||
<span>Back</span>
|
onClick={() => setPage("")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Referred Corporate ({total})</h2>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">Referred Corporate ({users.filter(referredCorporateFilter).length})</h2>
|
)}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<UserList user={user} filters={[referredCorporateFilter]} />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const InactiveReferredCorporateList = () => {
|
const InactiveReferredCorporateList = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<UserList
|
||||||
<div className="flex flex-col gap-4">
|
user={user}
|
||||||
<div
|
filters={[inactiveReferredCorporateFilter]}
|
||||||
onClick={() => setPage("")}
|
renderHeader={(total) => (
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
<div className="flex flex-col gap-4">
|
||||||
<BsArrowLeft className="text-xl" />
|
<div
|
||||||
<span>Back</span>
|
onClick={() => setPage("")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Inactive Referred Corporate ({total})</h2>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">Inactive Referred Corporate ({users.filter(inactiveReferredCorporateFilter).length})</h2>
|
)}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<UserList user={user} filters={[inactiveReferredCorporateFilter]} />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,38 +97,46 @@ export default function AgentDashboard({user}: Props) {
|
|||||||
const filter = (x: User) => x.type === "corporate";
|
const filter = (x: User) => x.type === "corporate";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<UserList
|
||||||
<div className="flex flex-col gap-4">
|
user={user}
|
||||||
<div
|
filters={[filter]}
|
||||||
onClick={() => setPage("")}
|
renderHeader={(total) => (
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
<div className="flex flex-col gap-4">
|
||||||
<BsArrowLeft className="text-xl" />
|
<div
|
||||||
<span>Back</span>
|
onClick={() => setPage("")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Corporate ({total})</h2>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">Corporate ({users.filter(filter).length})</h2>
|
)}
|
||||||
</div>
|
/>
|
||||||
<UserList user={user} filters={[filter]} />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const CorporatePaidStatusList = ({ paid }: {paid: Boolean}) => {
|
const CorporatePaidStatusList = ({paid}: {paid: Boolean}) => {
|
||||||
const list = paid ? done : pending;
|
const list = paid ? done : pending;
|
||||||
const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
|
const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<UserList
|
||||||
<div className="flex flex-col gap-4">
|
user={user}
|
||||||
<div
|
filters={[filter]}
|
||||||
onClick={() => setPage("")}
|
renderHeader={(total) => (
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
<div className="flex flex-col gap-4">
|
||||||
<BsArrowLeft className="text-xl" />
|
<div
|
||||||
<span>Back</span>
|
onClick={() => setPage("")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">
|
||||||
|
{paid ? "Payment Done" : "Pending Payment"} ({total})
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">{paid ? 'Payment Done' : 'Pending Payment'} ({list.length})</h2>
|
)}
|
||||||
</div>
|
/>
|
||||||
<UserList user={user} filters={[filter]} />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -153,13 +164,7 @@ export default function AgentDashboard({user}: Props) {
|
|||||||
value={users.filter(corporateFilter).length}
|
value={users.filter(corporateFilter).length}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard onClick={() => setPage("paymentdone")} Icon={BsCurrencyDollar} label="Payment Done" value={done.length} color="purple" />
|
||||||
onClick={() => setPage("paymentdone")}
|
|
||||||
Icon={BsCurrencyDollar}
|
|
||||||
label="Payment Done"
|
|
||||||
value={done.length}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("paymentpending")}
|
onClick={() => setPage("paymentpending")}
|
||||||
Icon={BsCurrencyDollar}
|
Icon={BsCurrencyDollar}
|
||||||
|
|||||||
@@ -1,126 +1,145 @@
|
|||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import { Module } from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import { Assignment } from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
import { calculateBandScore } from "@/utils/score";
|
import {calculateBandScore} from "@/utils/score";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {
|
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||||
BsBook,
|
import {usePDFDownload} from "@/hooks/usePDFDownload";
|
||||||
BsClipboard,
|
import {useAssignmentArchive} from "@/hooks/useAssignmentArchive";
|
||||||
BsHeadphones,
|
import {uniqBy} from "lodash";
|
||||||
BsMegaphone,
|
import {useAssignmentUnarchive} from "@/hooks/useAssignmentUnarchive";
|
||||||
BsPen,
|
import {useAssignmentRelease} from "@/hooks/useAssignmentRelease";
|
||||||
} from "react-icons/bs";
|
import {getUserName} from "@/utils/users";
|
||||||
import { usePDFDownload } from "@/hooks/usePDFDownload";
|
import {User} from "@/interfaces/user";
|
||||||
import { useAssignmentArchive } from "@/hooks/useAssignmentArchive";
|
|
||||||
import { uniqBy } from "lodash";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClick?: () => void;
|
users: User[];
|
||||||
allowDownload?: boolean;
|
onClick?: () => void;
|
||||||
reload?: Function;
|
allowDownload?: boolean;
|
||||||
allowArchive?: boolean;
|
reload?: Function;
|
||||||
|
allowArchive?: boolean;
|
||||||
|
allowUnarchive?: boolean;
|
||||||
|
allowExcelDownload?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssignmentCard({
|
export default function AssignmentCard({
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
assigner,
|
assigner,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
assignees,
|
assignees,
|
||||||
results,
|
results,
|
||||||
exams,
|
exams,
|
||||||
archived,
|
archived,
|
||||||
onClick,
|
onClick,
|
||||||
allowDownload,
|
allowDownload,
|
||||||
reload,
|
reload,
|
||||||
allowArchive,
|
allowArchive,
|
||||||
|
allowUnarchive,
|
||||||
|
allowExcelDownload,
|
||||||
|
users,
|
||||||
|
released,
|
||||||
}: Assignment & Props) {
|
}: Assignment & Props) {
|
||||||
const renderPdfIcon = usePDFDownload("assignments");
|
const renderPdfIcon = usePDFDownload("assignments");
|
||||||
const renderArchiveIcon = useAssignmentArchive(id, reload);
|
const renderExcelIcon = usePDFDownload("assignments", "excel");
|
||||||
|
const renderArchiveIcon = useAssignmentArchive(id, reload);
|
||||||
|
const renderUnarchiveIcon = useAssignmentUnarchive(id, reload);
|
||||||
|
const renderReleaseIcon = useAssignmentRelease(id, reload);
|
||||||
|
|
||||||
const calculateAverageModuleScore = (module: Module) => {
|
|
||||||
const resultModuleBandScores = results.map((r) => {
|
|
||||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
|
||||||
|
|
||||||
const correct = moduleStats.reduce(
|
const calculateAverageModuleScore = (module: Module) => {
|
||||||
(acc, curr) => acc + curr.score.correct,
|
const resultModuleBandScores = results.map((r) => {
|
||||||
0
|
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||||
);
|
|
||||||
const total = moduleStats.reduce(
|
|
||||||
(acc, curr) => acc + curr.score.total,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
return calculateBandScore(correct, total, module, r.type);
|
|
||||||
});
|
|
||||||
|
|
||||||
return resultModuleBandScores.length === 0
|
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
||||||
? -1
|
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
||||||
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
|
return calculateBandScore(correct, total, module, r.type);
|
||||||
results.length;
|
});
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / results.length;
|
||||||
<div
|
};
|
||||||
onClick={onClick}
|
|
||||||
className="border-mti-gray-platinum flex h-fit w-[350px] cursor-pointer flex-col gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow"
|
const uniqModules = uniqBy(exams, (x) => x.module);
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-3">
|
const shouldRenderPDF = () => {
|
||||||
<div className="flex flex-row justify-between">
|
if(released && allowDownload) {
|
||||||
<h3 className="text-xl font-semibold">{name}</h3>
|
// in order to be downloadable, the assignment has to be released
|
||||||
<div className="flex gap-2">
|
// the component should have the allowDownload prop
|
||||||
{allowDownload &&
|
// and the assignment should not have the level module
|
||||||
renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
return uniqModules.every(({ module }) => module !== 'level');
|
||||||
{allowArchive &&
|
}
|
||||||
!archived &&
|
|
||||||
renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
return false;
|
||||||
</div>
|
}
|
||||||
</div>
|
|
||||||
<ProgressBar
|
const shouldRenderExcel = () => {
|
||||||
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
|
if(released && allowExcelDownload) {
|
||||||
percentage={(results.length / assignees.length) * 100}
|
// in order to be downloadable, the assignment has to be released
|
||||||
label={`${results.length}/${assignees.length}`}
|
// the component should have the allowExcelDownload prop
|
||||||
className="h-5"
|
// and the assignment should have the level module
|
||||||
textClassName={
|
return uniqModules.some(({ module }) => module === 'level');
|
||||||
results.length / assignees.length < 0.5
|
}
|
||||||
? "!text-mti-gray-dim font-light"
|
|
||||||
: "text-white"
|
return false;
|
||||||
}
|
}
|
||||||
/>
|
|
||||||
</div>
|
return (
|
||||||
<span className="flex justify-between gap-1">
|
<div
|
||||||
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
|
onClick={onClick}
|
||||||
<span>-</span>
|
className="border-mti-gray-platinum flex h-fit w-[350px] cursor-pointer flex-col gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow">
|
||||||
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
|
<div className="flex flex-col gap-3">
|
||||||
</span>
|
<div className="flex flex-row justify-between">
|
||||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
<h3 className="text-xl font-semibold">{name}</h3>
|
||||||
{uniqBy(exams, (x) => x.module).map(({ module }) => (
|
<div className="flex gap-2">
|
||||||
<div
|
{shouldRenderPDF() && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
key={module}
|
{shouldRenderExcel() && renderExcelIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
className={clsx(
|
{allowArchive && !archived && renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
{allowUnarchive && archived && renderUnarchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
module === "reading" && "bg-ielts-reading",
|
{!released && renderReleaseIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
module === "listening" && "bg-ielts-listening",
|
</div>
|
||||||
module === "writing" && "bg-ielts-writing",
|
</div>
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
<ProgressBar
|
||||||
module === "level" && "bg-ielts-level"
|
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
|
||||||
)}
|
percentage={(results.length / assignees.length) * 100}
|
||||||
>
|
label={`${results.length}/${assignees.length}`}
|
||||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
className="h-5"
|
||||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"}
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
/>
|
||||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
</div>
|
||||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
<div className="flex flex-col gap-1">
|
||||||
{calculateAverageModuleScore(module) > -1 && (
|
<span className="flex justify-between gap-1">
|
||||||
<span className="text-sm">
|
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
{calculateAverageModuleScore(module).toFixed(1)}
|
<span>-</span>
|
||||||
</span>
|
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
)}
|
</span>
|
||||||
</div>
|
<span>Assigner: {getUserName(users.find((x) => x.id === assigner))}</span>
|
||||||
))}
|
</div>
|
||||||
</div>
|
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||||
</div>
|
{uniqModules.map(({module}) => (
|
||||||
);
|
<div
|
||||||
|
key={module}
|
||||||
|
className={clsx(
|
||||||
|
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||||
|
module === "reading" && "bg-ielts-reading",
|
||||||
|
module === "listening" && "bg-ielts-listening",
|
||||||
|
module === "writing" && "bg-ielts-writing",
|
||||||
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
|
module === "level" && "bg-ielts-level",
|
||||||
|
)}>
|
||||||
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
|
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||||
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
|
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||||
|
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||||
|
{calculateAverageModuleScore(module) > -1 && (
|
||||||
|
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Input from "@/components/Low/Input";
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useState} from "react";
|
import {useEffect, useMemo, useState} from "react";
|
||||||
import {BsBook, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
import {BsBook, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
||||||
import {generate} from "random-words";
|
import {generate} from "random-words";
|
||||||
import {capitalize} from "lodash";
|
import {capitalize} from "lodash";
|
||||||
@@ -16,27 +16,39 @@ import moment from "moment";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {getExam} from "@/utils/exams";
|
import {getExam} from "@/utils/exams";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import {uuidv4} from "@firebase/util";
|
|
||||||
import {Assignment} from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import {InstructorGender, Variant} from "@/interfaces/exam";
|
import {InstructorGender, Variant} from "@/interfaces/exam";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
|
import useExams from "@/hooks/useExams";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isCreating: boolean;
|
isCreating: boolean;
|
||||||
assigner: string;
|
|
||||||
users: User[];
|
users: User[];
|
||||||
|
user: User;
|
||||||
groups: Group[];
|
groups: Group[];
|
||||||
assignment?: Assignment;
|
assignment?: Assignment;
|
||||||
cancelCreation: () => void;
|
cancelCreation: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssignmentCreator({isCreating, assignment, assigner, groups, users, cancelCreation}: Props) {
|
export default function AssignmentCreator({isCreating, assignment, user, groups, users, cancelCreation}: Props) {
|
||||||
const [selectedModules, setSelectedModules] = useState<Module[]>(assignment?.exams.map((e) => e.module) || []);
|
const [selectedModules, setSelectedModules] = useState<Module[]>(assignment?.exams.map((e) => e.module) || []);
|
||||||
const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []);
|
const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []);
|
||||||
const [name, setName] = useState(assignment?.name || generate({minLength: 6, maxLength: 8, min: 2, max: 3, join: " ", formatter: capitalize}));
|
const [teachers, setTeachers] = useState<string[]>(!!assignment ? assignment.teachers || [] : [...(user.type === "teacher" ? [user.id] : [])]);
|
||||||
|
const [name, setName] = useState(
|
||||||
|
assignment?.name ||
|
||||||
|
generate({
|
||||||
|
minLength: 6,
|
||||||
|
maxLength: 8,
|
||||||
|
min: 2,
|
||||||
|
max: 3,
|
||||||
|
join: " ",
|
||||||
|
formatter: capitalize,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [startDate, setStartDate] = useState<Date | null>(assignment ? moment(assignment.startDate).toDate() : new Date());
|
const [startDate, setStartDate] = useState<Date | null>(assignment ? moment(assignment.startDate).toDate() : moment().add(1, "hour").toDate());
|
||||||
|
|
||||||
const [endDate, setEndDate] = useState<Date | null>(
|
const [endDate, setEndDate] = useState<Date | null>(
|
||||||
assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(),
|
assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(),
|
||||||
);
|
);
|
||||||
@@ -44,6 +56,22 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied");
|
const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied");
|
||||||
// creates a new exam for each assignee or just one exam for all assignees
|
// creates a new exam for each assignee or just one exam for all assignees
|
||||||
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
|
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
|
||||||
|
const [released, setReleased] = useState<boolean>(assignment?.released || false);
|
||||||
|
|
||||||
|
const [autoStart, setAutostart] = useState<boolean>(assignment?.autoStart || false);
|
||||||
|
const [autoStartDate, setAutoStartDate] = useState<Date | null>(assignment ? moment(assignment.autoStartDate).toDate() : new Date());
|
||||||
|
|
||||||
|
const [useRandomExams, setUseRandomExams] = useState(true);
|
||||||
|
const [examIDs, setExamIDs] = useState<{id: string; module: Module}[]>([]);
|
||||||
|
|
||||||
|
const {exams} = useExams();
|
||||||
|
|
||||||
|
const userStudents = useMemo(() => users.filter((x) => x.type === "student"), [users]);
|
||||||
|
const userTeachers = useMemo(() => users.filter((x) => x.type === "teacher"), [users]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module)));
|
||||||
|
}, [selectedModules]);
|
||||||
|
|
||||||
const toggleModule = (module: Module) => {
|
const toggleModule = (module: Module) => {
|
||||||
const modules = selectedModules.filter((x) => x !== module);
|
const modules = selectedModules.filter((x) => x !== module);
|
||||||
@@ -54,6 +82,10 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
setAssignees((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id]));
|
setAssignees((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id]));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleTeacher = (user: User) => {
|
||||||
|
setTeachers((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id]));
|
||||||
|
};
|
||||||
|
|
||||||
const createAssignment = () => {
|
const createAssignment = () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
@@ -61,11 +93,16 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
assignees,
|
assignees,
|
||||||
name,
|
name,
|
||||||
startDate,
|
startDate,
|
||||||
|
examIDs: !useRandomExams ? examIDs : undefined,
|
||||||
endDate,
|
endDate,
|
||||||
selectedModules,
|
selectedModules,
|
||||||
generateMultiple,
|
generateMultiple,
|
||||||
|
teachers,
|
||||||
variant,
|
variant,
|
||||||
instructorGender,
|
instructorGender,
|
||||||
|
released,
|
||||||
|
autoStart,
|
||||||
|
autoStartDate,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
|
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
|
||||||
@@ -97,15 +134,32 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startAssignment = () => {
|
||||||
|
if (assignment) {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
axios
|
||||||
|
.post(`/api/assignments/${assignment.id}/start`)
|
||||||
|
.then(() => {
|
||||||
|
toast.success(`The assignment "${name}" has been started successfully!`);
|
||||||
|
cancelCreation();
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.log(e);
|
||||||
|
toast.error("Something went wrong, please try again later!");
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isCreating} onClose={cancelCreation} title="New Assignment">
|
<Modal isOpen={isCreating} onClose={cancelCreation} title="New Assignment">
|
||||||
<div className="w-full flex flex-col gap-4">
|
<div className="w-full flex flex-col gap-4">
|
||||||
<section className="w-full grid -md:grid-cols-1 md:grid-cols-2 place-items-center lg:grid-cols-6 -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8">
|
<section className="w-full grid -md:grid-cols-1 md:grid-cols-3 place-items-center -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8">
|
||||||
<div
|
<div
|
||||||
onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
|
onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||||
"lg:col-span-2",
|
|
||||||
selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||||
)}>
|
)}>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||||
@@ -122,7 +176,6 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
|
onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||||
"lg:col-span-2",
|
|
||||||
selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||||
)}>
|
)}>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||||
@@ -135,11 +188,30 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
||||||
{selectedModules.includes("listening") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
{selectedModules.includes("listening") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={
|
||||||
|
(!selectedModules.includes("level") && selectedModules.length === 0) || selectedModules.includes("level")
|
||||||
|
? () => toggleModule("level")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className={clsx(
|
||||||
|
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||||
|
selectedModules.includes("level") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||||
|
)}>
|
||||||
|
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||||
|
<BsClipboard className="text-white w-7 h-7" />
|
||||||
|
</div>
|
||||||
|
<span className="ml-8 font-semibold">Level</span>
|
||||||
|
{!selectedModules.includes("level") && selectedModules.length === 0 && (
|
||||||
|
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
||||||
|
)}
|
||||||
|
{!selectedModules.includes("level") && selectedModules.length > 0 && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
||||||
|
{selectedModules.includes("level") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
|
onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||||
"lg:col-span-2",
|
|
||||||
selectedModules.includes("writing") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
selectedModules.includes("writing") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||||
)}>
|
)}>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||||
@@ -156,7 +228,6 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
|
onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
||||||
"lg:col-span-3",
|
|
||||||
selectedModules.includes("speaking") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
selectedModules.includes("speaking") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||||
)}>
|
)}>
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
||||||
@@ -169,34 +240,13 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
||||||
{selectedModules.includes("speaking") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
{selectedModules.includes("speaking") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
onClick={
|
|
||||||
(!selectedModules.includes("level") && selectedModules.length === 0) || selectedModules.includes("level")
|
|
||||||
? () => toggleModule("level")
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
className={clsx(
|
|
||||||
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
|
|
||||||
"lg:col-span-3",
|
|
||||||
selectedModules.includes("level") ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
|
||||||
)}>
|
|
||||||
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
|
|
||||||
<BsClipboard className="text-white w-7 h-7" />
|
|
||||||
</div>
|
|
||||||
<span className="ml-8 font-semibold">Level</span>
|
|
||||||
{!selectedModules.includes("level") && selectedModules.length === 0 && (
|
|
||||||
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
|
|
||||||
)}
|
|
||||||
{!selectedModules.includes("level") && selectedModules.length > 0 && <BsXCircle className="text-mti-red-light w-8 h-8" />}
|
|
||||||
{selectedModules.includes("level") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Input type="text" name="name" onChange={(e) => setName(e)} defaultValue={name} label="Assignment Name" required />
|
<Input type="text" name="name" onChange={(e) => setName(e)} defaultValue={name} label="Assignment Name" required />
|
||||||
|
|
||||||
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
|
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Start Date *</label>
|
<label className="font-normal text-base text-mti-gray-dim">Limit Start Date *</label>
|
||||||
<ReactDatePicker
|
<ReactDatePicker
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
@@ -227,21 +277,75 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
onChange={(date) => setEndDate(date)}
|
onChange={(date) => setEndDate(date)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{autoStart && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Automatic Start Date *</label>
|
||||||
|
<ReactDatePicker
|
||||||
|
className={clsx(
|
||||||
|
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"hover:border-mti-purple tooltip z-10",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
)}
|
||||||
|
popperClassName="!z-20"
|
||||||
|
filterTime={(date) => moment(date).isSameOrAfter(new Date())}
|
||||||
|
dateFormat="dd/MM/yyyy HH:mm"
|
||||||
|
selected={autoStartDate}
|
||||||
|
showTimeSelect
|
||||||
|
onChange={(date) => setAutoStartDate(date)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 w-full">
|
{selectedModules.includes("speaking") && (
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor's Gender</label>
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<Select
|
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor's Gender</label>
|
||||||
value={{value: instructorGender, label: capitalize(instructorGender)}}
|
<Select
|
||||||
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)}
|
value={{
|
||||||
disabled={!selectedModules.includes("speaking") || !!assignment}
|
value: instructorGender,
|
||||||
options={[
|
label: capitalize(instructorGender),
|
||||||
{value: "male", label: "Male"},
|
}}
|
||||||
{value: "female", label: "Female"},
|
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)}
|
||||||
{value: "varied", label: "Varied"},
|
disabled={!selectedModules.includes("speaking") || !!assignment}
|
||||||
]}
|
options={[
|
||||||
/>
|
{value: "male", label: "Male"},
|
||||||
</div>
|
{value: "female", label: "Female"},
|
||||||
|
{value: "varied", label: "Varied"},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedModules.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<Checkbox isChecked={useRandomExams} onChange={setUseRandomExams}>
|
||||||
|
Random Exams
|
||||||
|
</Checkbox>
|
||||||
|
{!useRandomExams && (
|
||||||
|
<div className="grid md:grid-cols-2 w-full gap-4">
|
||||||
|
{selectedModules.map((module) => (
|
||||||
|
<div key={module} className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">{capitalize(module)} Exam</label>
|
||||||
|
<Select
|
||||||
|
value={{
|
||||||
|
value: examIDs.find((e) => e.module === module)?.id || null,
|
||||||
|
label: examIDs.find((e) => e.module === module)?.id || "",
|
||||||
|
}}
|
||||||
|
onChange={(value) =>
|
||||||
|
value
|
||||||
|
? setExamIDs((prev) => [...prev.filter((x) => x.module !== module), {id: value.value!, module}])
|
||||||
|
: setExamIDs((prev) => prev.filter((x) => x.module !== module))
|
||||||
|
}
|
||||||
|
options={exams
|
||||||
|
.filter((x) => !x.isDiagnostic && x.module === module)
|
||||||
|
.map((x) => ({value: x.id, label: x.id}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="w-full flex flex-col gap-3">
|
<section className="w-full flex flex-col gap-3">
|
||||||
<span className="font-semibold">Assignees ({assignees.length} selected)</span>
|
<span className="font-semibold">Assignees ({assignees.length} selected)</span>
|
||||||
@@ -268,7 +372,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap -md:justify-center gap-4">
|
<div className="flex flex-wrap -md:justify-center gap-4">
|
||||||
{users.map((user) => (
|
{userStudents.map((user) => (
|
||||||
<div
|
<div
|
||||||
onClick={() => toggleAssignee(user)}
|
onClick={() => toggleAssignee(user)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -299,31 +403,108 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div className="flex flex-col gap-4 w-full items-end">
|
|
||||||
|
{user.type !== "teacher" && (
|
||||||
|
<section className="w-full flex flex-col gap-3">
|
||||||
|
<span className="font-semibold">Teachers ({teachers.length} selected)</span>
|
||||||
|
<div className="flex gap-4 overflow-x-scroll scrollbar-hide">
|
||||||
|
{groups.map((g) => (
|
||||||
|
<button
|
||||||
|
key={g.id}
|
||||||
|
onClick={() => {
|
||||||
|
const groupStudentIds = users.filter((u) => g.participants.includes(u.id)).map((u) => u.id);
|
||||||
|
if (groupStudentIds.every((u) => teachers.includes(u))) {
|
||||||
|
setTeachers((prev) => prev.filter((a) => !groupStudentIds.includes(a)));
|
||||||
|
} else {
|
||||||
|
setTeachers((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
users.filter((u) => g.participants.includes(u.id)).every((u) => teachers.includes(u.id)) &&
|
||||||
|
"!bg-mti-purple-light !text-white",
|
||||||
|
)}>
|
||||||
|
{g.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap -md:justify-center gap-4">
|
||||||
|
{userTeachers.map((user) => (
|
||||||
|
<div
|
||||||
|
onClick={() => toggleTeacher(user)}
|
||||||
|
className={clsx(
|
||||||
|
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
|
||||||
|
"transition ease-in-out duration-300",
|
||||||
|
teachers.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum",
|
||||||
|
)}
|
||||||
|
key={user.id}>
|
||||||
|
<span className="flex flex-col gap-0 justify-center">
|
||||||
|
<span className="font-semibold">{user.name}</span>
|
||||||
|
<span className="text-sm opacity-80">{user.email}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-mti-black/80 text-sm whitespace-pre-wrap mt-2">
|
||||||
|
Groups:{" "}
|
||||||
|
{groups
|
||||||
|
.filter((g) => g.participants.includes(user.id))
|
||||||
|
.map((g) => g.name)
|
||||||
|
.join(", ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-4 w-full items-end">
|
||||||
<Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
|
<Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
|
||||||
Full length exams
|
Full length exams
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
|
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
|
||||||
Generate different exams
|
Generate different exams
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
|
<Checkbox isChecked={released} onChange={() => setReleased((d) => !d)}>
|
||||||
|
Auto release results
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox isChecked={autoStart} onChange={() => setAutostart((d) => !d)}>
|
||||||
|
Auto start exam
|
||||||
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 w-full justify-end">
|
<div className="flex gap-4 w-full justify-end">
|
||||||
<Button className="w-full max-w-[200px]" variant="outline" onClick={cancelCreation} disabled={isLoading} isLoading={isLoading}>
|
<Button className="w-full max-w-[200px]" variant="outline" onClick={cancelCreation} disabled={isLoading} isLoading={isLoading}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
{assignment && (
|
{assignment && (
|
||||||
<Button
|
<>
|
||||||
className="w-full max-w-[200px]"
|
<Button
|
||||||
color="red"
|
className="w-full max-w-[200px]"
|
||||||
variant="outline"
|
color="green"
|
||||||
onClick={deleteAssignment}
|
variant="outline"
|
||||||
disabled={isLoading}
|
onClick={startAssignment}
|
||||||
isLoading={isLoading}>
|
disabled={isLoading || moment().isAfter(startDate)}
|
||||||
Delete
|
isLoading={isLoading}>
|
||||||
</Button>
|
Start
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
color="red"
|
||||||
|
variant="outline"
|
||||||
|
onClick={deleteAssignment}
|
||||||
|
disabled={isLoading}
|
||||||
|
isLoading={isLoading}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
disabled={selectedModules.length === 0 || !name || !startDate || !endDate || assignees.length === 0}
|
disabled={
|
||||||
|
selectedModules.length === 0 ||
|
||||||
|
!name ||
|
||||||
|
!startDate ||
|
||||||
|
!endDate ||
|
||||||
|
assignees.length === 0 ||
|
||||||
|
(!useRandomExams && examIDs.length < selectedModules.length)
|
||||||
|
}
|
||||||
className="w-full max-w-[200px]"
|
className="w-full max-w-[200px]"
|
||||||
onClick={createAssignment}
|
onClick={createAssignment}
|
||||||
isLoading={isLoading}>
|
isLoading={isLoading}>
|
||||||
|
|||||||
@@ -2,306 +2,433 @@ import Button from "@/components/Low/Button";
|
|||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {Module} from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import { Assignment } from "@/interfaces/results";
|
||||||
import {Stat, User} from "@/interfaces/user";
|
import { Stat, User } from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {getExamById} from "@/utils/exams";
|
import { getExamById } from "@/utils/exams";
|
||||||
import {sortByModule} from "@/utils/moduleUtils";
|
import { sortByModule } from "@/utils/moduleUtils";
|
||||||
import {calculateBandScore} from "@/utils/score";
|
import { calculateBandScore } from "@/utils/score";
|
||||||
import {convertToUserSolutions} from "@/utils/stats";
|
import { convertToUserSolutions } from "@/utils/stats";
|
||||||
|
import { getUserName } from "@/utils/users";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize, uniqBy} from "lodash";
|
import { capitalize, uniqBy } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
import {
|
||||||
import {toast} from "react-toastify";
|
BsBook,
|
||||||
|
BsClipboard,
|
||||||
|
BsHeadphones,
|
||||||
|
BsMegaphone,
|
||||||
|
BsPen,
|
||||||
|
} from "react-icons/bs";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { futureAssignmentFilter } from "@/utils/assignments";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
assignment?: Assignment;
|
assignment?: Assignment;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
||||||
const {users} = useUsers();
|
const { users } = useUsers();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
const setExams = useExamStore((state) => state.setExams);
|
||||||
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
||||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||||
|
|
||||||
const deleteAssignment = async () => {
|
const deleteAssignment = async () => {
|
||||||
if (!confirm("Are you sure you want to delete this assignment?")) return;
|
if (!confirm("Are you sure you want to delete this assignment?")) return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.delete(`/api/assignments/${assignment?.id}`)
|
.delete(`/api/assignments/${assignment?.id}`)
|
||||||
.then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`))
|
.then(() =>
|
||||||
.catch(() => toast.error("Something went wrong, please try again later."))
|
toast.success(
|
||||||
.finally(onClose);
|
`Successfully deleted the assignment "${assignment?.name}".`
|
||||||
};
|
)
|
||||||
|
)
|
||||||
|
.catch(() => toast.error("Something went wrong, please try again later."))
|
||||||
|
.finally(onClose);
|
||||||
|
};
|
||||||
|
|
||||||
const formatTimestamp = (timestamp: string) => {
|
const startAssignment = () => {
|
||||||
const date = moment(parseInt(timestamp));
|
if (assignment) {
|
||||||
const formatter = "YYYY/MM/DD - HH:mm";
|
axios
|
||||||
|
.post(`/api/assignments/${assignment.id}/start`)
|
||||||
|
.then(() => {
|
||||||
|
toast.success(
|
||||||
|
`The assignment "${assignment.name}" has been started successfully!`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.log(e);
|
||||||
|
toast.error("Something went wrong, please try again later!");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return date.format(formatter);
|
const formatTimestamp = (timestamp: string) => {
|
||||||
};
|
const date = moment(parseInt(timestamp));
|
||||||
|
const formatter = "YYYY/MM/DD - HH:mm";
|
||||||
|
|
||||||
const calculateAverageModuleScore = (module: Module) => {
|
return date.format(formatter);
|
||||||
if (!assignment) return -1;
|
};
|
||||||
|
|
||||||
const resultModuleBandScores = assignment.results.map((r) => {
|
const calculateAverageModuleScore = (module: Module) => {
|
||||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
if (!assignment) return -1;
|
||||||
|
|
||||||
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
const resultModuleBandScores = assignment.results.map((r) => {
|
||||||
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||||
return calculateBandScore(correct, total, module, r.type);
|
|
||||||
});
|
|
||||||
|
|
||||||
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
|
const correct = moduleStats.reduce(
|
||||||
};
|
(acc, curr) => acc + curr.score.correct,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const total = moduleStats.reduce(
|
||||||
|
(acc, curr) => acc + curr.score.total,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return calculateBandScore(correct, total, module, r.type);
|
||||||
|
});
|
||||||
|
|
||||||
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
return resultModuleBandScores.length === 0
|
||||||
const scores: {
|
? -1
|
||||||
[key in Module]: {total: number; missing: number; correct: number};
|
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
|
||||||
} = {
|
assignment.results.length;
|
||||||
reading: {
|
};
|
||||||
total: 0,
|
|
||||||
correct: 0,
|
|
||||||
missing: 0,
|
|
||||||
},
|
|
||||||
listening: {
|
|
||||||
total: 0,
|
|
||||||
correct: 0,
|
|
||||||
missing: 0,
|
|
||||||
},
|
|
||||||
writing: {
|
|
||||||
total: 0,
|
|
||||||
correct: 0,
|
|
||||||
missing: 0,
|
|
||||||
},
|
|
||||||
speaking: {
|
|
||||||
total: 0,
|
|
||||||
correct: 0,
|
|
||||||
missing: 0,
|
|
||||||
},
|
|
||||||
level: {
|
|
||||||
total: 0,
|
|
||||||
correct: 0,
|
|
||||||
missing: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
stats.forEach((x) => {
|
const aggregateScoresByModule = (
|
||||||
scores[x.module!] = {
|
stats: Stat[]
|
||||||
total: scores[x.module!].total + x.score.total,
|
): { module: Module; total: number; missing: number; correct: number }[] => {
|
||||||
correct: scores[x.module!].correct + x.score.correct,
|
const scores: {
|
||||||
missing: scores[x.module!].missing + x.score.missing,
|
[key in Module]: { total: number; missing: number; correct: number };
|
||||||
};
|
} = {
|
||||||
});
|
reading: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
listening: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
writing: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
speaking: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
level: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
return Object.keys(scores)
|
stats.forEach((x) => {
|
||||||
.filter((x) => scores[x as Module].total > 0)
|
scores[x.module!] = {
|
||||||
.map((x) => ({module: x as Module, ...scores[x as Module]}));
|
total: scores[x.module!].total + x.score.total,
|
||||||
};
|
correct: scores[x.module!].correct + x.score.correct,
|
||||||
|
missing: scores[x.module!].missing + x.score.missing,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
|
return Object.keys(scores)
|
||||||
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
.filter((x) => scores[x as Module].total > 0)
|
||||||
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
||||||
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
};
|
||||||
|
|
||||||
const aggregatedLevels = aggregatedScores.map((x) => ({
|
const customContent = (
|
||||||
module: x.module,
|
stats: Stat[],
|
||||||
level: calculateBandScore(x.correct, x.total, x.module, focus),
|
user: string,
|
||||||
}));
|
focus: "academic" | "general"
|
||||||
|
) => {
|
||||||
|
const correct = stats.reduce(
|
||||||
|
(accumulator, current) => accumulator + current.score.correct,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const total = stats.reduce(
|
||||||
|
(accumulator, current) => accumulator + current.score.total,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const aggregatedScores = aggregateScoresByModule(stats).filter(
|
||||||
|
(x) => x.total > 0
|
||||||
|
);
|
||||||
|
|
||||||
const timeSpent = stats[0].timeSpent;
|
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||||
|
module: x.module,
|
||||||
|
level: calculateBandScore(x.correct, x.total, x.module, focus),
|
||||||
|
}));
|
||||||
|
|
||||||
const selectExam = () => {
|
const timeSpent = stats[0].timeSpent;
|
||||||
const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
|
|
||||||
|
|
||||||
Promise.all(examPromises).then((exams) => {
|
const selectExam = () => {
|
||||||
if (exams.every((x) => !!x)) {
|
const examPromises = uniqBy(stats, "exam").map((stat) =>
|
||||||
setUserSolutions(convertToUserSolutions(stats));
|
getExamById(stat.module, stat.exam)
|
||||||
setShowSolutions(true);
|
);
|
||||||
setExams(exams.map((x) => x!).sort(sortByModule));
|
|
||||||
setSelectedModules(
|
|
||||||
exams
|
|
||||||
.map((x) => x!)
|
|
||||||
.sort(sortByModule)
|
|
||||||
.map((x) => x!.module),
|
|
||||||
);
|
|
||||||
router.push("/exercises");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const content = (
|
Promise.all(examPromises).then((exams) => {
|
||||||
<>
|
if (exams.every((x) => !!x)) {
|
||||||
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
|
setUserSolutions(convertToUserSolutions(stats));
|
||||||
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
|
setShowSolutions(true);
|
||||||
<span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
|
setExams(exams.map((x) => x!).sort(sortByModule));
|
||||||
{timeSpent && (
|
setSelectedModules(
|
||||||
<>
|
exams
|
||||||
<span className="md:hidden 2xl:flex">• </span>
|
.map((x) => x!)
|
||||||
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
|
.sort(sortByModule)
|
||||||
</>
|
.map((x) => x!.module)
|
||||||
)}
|
);
|
||||||
</div>
|
router.push("/exercises");
|
||||||
<span
|
}
|
||||||
className={clsx(
|
});
|
||||||
correct / total >= 0.7 && "text-mti-purple",
|
};
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
|
||||||
correct / total < 0.3 && "text-mti-rose",
|
|
||||||
)}>
|
|
||||||
Level{" "}
|
|
||||||
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-1">
|
const content = (
|
||||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
<>
|
||||||
{aggregatedLevels.map(({module, level}) => (
|
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
|
||||||
<div
|
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
|
||||||
key={module}
|
<span className="font-medium">
|
||||||
className={clsx(
|
{formatTimestamp(stats[0].date.toString())}
|
||||||
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
</span>
|
||||||
module === "reading" && "bg-ielts-reading",
|
{timeSpent && (
|
||||||
module === "listening" && "bg-ielts-listening",
|
<>
|
||||||
module === "writing" && "bg-ielts-writing",
|
<span className="md:hidden 2xl:flex">• </span>
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
<span className="text-sm">
|
||||||
module === "level" && "bg-ielts-level",
|
{Math.floor(timeSpent / 60)} minutes
|
||||||
)}>
|
</span>
|
||||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
</>
|
||||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
)}
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
</div>
|
||||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
<span
|
||||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
className={clsx(
|
||||||
<span className="text-sm">{level.toFixed(1)}</span>
|
correct / total >= 0.7 && "text-mti-purple",
|
||||||
</div>
|
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||||
))}
|
correct / total < 0.3 && "text-mti-rose"
|
||||||
</div>
|
)}
|
||||||
</div>
|
>
|
||||||
</>
|
Level{" "}
|
||||||
);
|
{(
|
||||||
|
aggregatedLevels.reduce(
|
||||||
|
(accumulator, current) => accumulator + current.level,
|
||||||
|
0
|
||||||
|
) / aggregatedLevels.length
|
||||||
|
).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
return (
|
<div className="flex w-full flex-col gap-1">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||||
<span>
|
{aggregatedLevels.map(({ module, level }) => (
|
||||||
{(() => {
|
<div
|
||||||
const student = users.find((u) => u.id === user);
|
key={module}
|
||||||
return `${student?.name} (${student?.email})`;
|
className={clsx(
|
||||||
})()}
|
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||||
</span>
|
module === "reading" && "bg-ielts-reading",
|
||||||
<div
|
module === "listening" && "bg-ielts-listening",
|
||||||
key={user}
|
module === "writing" && "bg-ielts-writing",
|
||||||
className={clsx(
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
|
module === "level" && "bg-ielts-level"
|
||||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
)}
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
>
|
||||||
correct / total < 0.3 && "hover:border-mti-rose",
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
)}
|
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||||
onClick={selectExam}
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
role="button">
|
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||||
{content}
|
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||||
</div>
|
<span className="text-sm">{level.toFixed(1)}</span>
|
||||||
<div
|
</div>
|
||||||
key={user}
|
))}
|
||||||
className={clsx(
|
</div>
|
||||||
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
|
</div>
|
||||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
</>
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
);
|
||||||
correct / total < 0.3 && "hover:border-mti-rose",
|
|
||||||
)}
|
|
||||||
data-tip="Your screen size is too small to view previous exams."
|
|
||||||
role="button">
|
|
||||||
{content}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
|
<div className="flex flex-col gap-2">
|
||||||
<div className="mt-4 flex w-full flex-col gap-4">
|
<span>
|
||||||
<ProgressBar
|
{(() => {
|
||||||
color="purple"
|
const student = users.find((u) => u.id === user);
|
||||||
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
return `${student?.name} (${student?.email})`;
|
||||||
className="h-6"
|
})()}
|
||||||
textClassName={
|
</span>
|
||||||
(assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"
|
<div
|
||||||
}
|
key={user}
|
||||||
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100}
|
className={clsx(
|
||||||
/>
|
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
|
||||||
<div className="flex items-start gap-8">
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
<div className="flex flex-col gap-2">
|
correct / total >= 0.3 &&
|
||||||
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
|
correct / total < 0.7 &&
|
||||||
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
|
"hover:border-mti-red",
|
||||||
</div>
|
correct / total < 0.3 && "hover:border-mti-rose"
|
||||||
<span>
|
)}
|
||||||
Assignees:{" "}
|
onClick={selectExam}
|
||||||
{users
|
role="button"
|
||||||
.filter((u) => assignment?.assignees.includes(u.id))
|
>
|
||||||
.map((u) => `${u.name} (${u.email})`)
|
{content}
|
||||||
.join(", ")}
|
</div>
|
||||||
</span>
|
<div
|
||||||
</div>
|
key={user}
|
||||||
<div className="flex flex-col gap-2">
|
className={clsx(
|
||||||
<span className="text-xl font-bold">Average Scores</span>
|
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
|
||||||
<div className="-md:mt-2 flex w-full items-center gap-4">
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
{assignment &&
|
correct / total >= 0.3 &&
|
||||||
uniqBy(assignment.exams, (x) => x.module).map(({module}) => (
|
correct / total < 0.7 &&
|
||||||
<div
|
"hover:border-mti-red",
|
||||||
data-tip={capitalize(module)}
|
correct / total < 0.3 && "hover:border-mti-rose"
|
||||||
key={module}
|
)}
|
||||||
className={clsx(
|
data-tip="Your screen size is too small to view previous exams."
|
||||||
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
role="button"
|
||||||
module === "reading" && "bg-ielts-reading",
|
>
|
||||||
module === "listening" && "bg-ielts-listening",
|
{content}
|
||||||
module === "writing" && "bg-ielts-writing",
|
</div>
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
</div>
|
||||||
module === "level" && "bg-ielts-level",
|
);
|
||||||
)}>
|
};
|
||||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
|
||||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
|
||||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
|
||||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
|
||||||
{calculateAverageModuleScore(module) > -1 && (
|
|
||||||
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span className="text-xl font-bold">
|
|
||||||
Results ({assignment?.results.length}/{assignment?.assignees.length})
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
{assignment && assignment?.results.length > 0 && (
|
|
||||||
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
|
|
||||||
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{assignment && assignment?.results.length === 0 && <span className="ml-1 font-semibold">No results yet...</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-4 w-full items-center justify-end">
|
const shouldRenderStart = () => {
|
||||||
{assignment && (assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && (
|
if (assignment) {
|
||||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={deleteAssignment}>
|
if (futureAssignmentFilter(assignment)) {
|
||||||
Delete
|
return true;
|
||||||
</Button>
|
}
|
||||||
)}
|
}
|
||||||
<Button onClick={onClose} className="w-full max-w-[200px]">
|
|
||||||
Close
|
return false;
|
||||||
</Button>
|
};
|
||||||
</div>
|
|
||||||
</div>
|
return (
|
||||||
</Modal>
|
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
|
||||||
);
|
<div className="mt-4 flex w-full flex-col gap-4">
|
||||||
|
<ProgressBar
|
||||||
|
color="purple"
|
||||||
|
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
||||||
|
className="h-6"
|
||||||
|
textClassName={
|
||||||
|
(assignment?.results.length || 0) /
|
||||||
|
(assignment?.assignees.length || 1) <
|
||||||
|
0.5
|
||||||
|
? "!text-mti-gray-dim font-light"
|
||||||
|
: "text-white"
|
||||||
|
}
|
||||||
|
percentage={
|
||||||
|
((assignment?.results.length || 0) /
|
||||||
|
(assignment?.assignees.length || 1)) *
|
||||||
|
100
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex items-start gap-8">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span>
|
||||||
|
Start Date:{" "}
|
||||||
|
{moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span>
|
||||||
|
Assignees:{" "}
|
||||||
|
{users
|
||||||
|
.filter((u) => assignment?.assignees.includes(u.id))
|
||||||
|
.map((u) => `${u.name} (${u.email})`)
|
||||||
|
.join(", ")}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Assigner:{" "}
|
||||||
|
{getUserName(users.find((x) => x.id === assignment?.assigner))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-xl font-bold">Average Scores</span>
|
||||||
|
<div className="-md:mt-2 flex w-full items-center gap-4">
|
||||||
|
{assignment &&
|
||||||
|
uniqBy(assignment.exams, (x) => x.module).map(({ module }) => (
|
||||||
|
<div
|
||||||
|
data-tip={capitalize(module)}
|
||||||
|
key={module}
|
||||||
|
className={clsx(
|
||||||
|
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||||
|
module === "reading" && "bg-ielts-reading",
|
||||||
|
module === "listening" && "bg-ielts-listening",
|
||||||
|
module === "writing" && "bg-ielts-writing",
|
||||||
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
|
module === "level" && "bg-ielts-level"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
|
{module === "listening" && (
|
||||||
|
<BsHeadphones className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
|
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||||
|
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||||
|
{calculateAverageModuleScore(module) > -1 && (
|
||||||
|
<span className="text-sm">
|
||||||
|
{calculateAverageModuleScore(module).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-xl font-bold">
|
||||||
|
Results ({assignment?.results.length}/{assignment?.assignees.length}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
{assignment && assignment?.results.length > 0 && (
|
||||||
|
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
|
||||||
|
{assignment.results.map((r) =>
|
||||||
|
customContent(r.stats, r.user, r.type)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{assignment && assignment?.results.length === 0 && (
|
||||||
|
<span className="ml-1 font-semibold">No results yet...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 w-full items-center justify-end">
|
||||||
|
{assignment &&
|
||||||
|
(assignment.results.length === assignment.assignees.length ||
|
||||||
|
moment().isAfter(moment(assignment.endDate))) && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={deleteAssignment}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{/** if the assignment is not deemed as active yet, display start */}
|
||||||
|
{shouldRenderStart() && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="green"
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={startAssignment}
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={onClose} className="w-full max-w-[200px]">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useStats from "@/hooks/useStats";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers, { userHashStudent, userHashTeacher, userHashCorporate} from "@/hooks/useUsers";
|
||||||
import {CorporateUser, Group, Stat, User} from "@/interfaces/user";
|
import {CorporateUser, Group, MasterCorporateUser, Stat, User } from "@/interfaces/user";
|
||||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
import {dateSorter} from "@/utils";
|
import {dateSorter} from "@/utils";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useMemo, useState} from "react";
|
||||||
import {
|
import {
|
||||||
BsArrowLeft,
|
BsArrowLeft,
|
||||||
BsClipboard2Data,
|
BsClipboard2Data,
|
||||||
@@ -23,10 +23,13 @@ import {
|
|||||||
BsPersonBadge,
|
BsPersonBadge,
|
||||||
BsPersonCheck,
|
BsPersonCheck,
|
||||||
BsPeople,
|
BsPeople,
|
||||||
|
BsArrowRepeat,
|
||||||
|
BsPlus,
|
||||||
|
BsEnvelopePaper,
|
||||||
} from "react-icons/bs";
|
} from "react-icons/bs";
|
||||||
import UserCard from "@/components/UserCard";
|
import UserCard from "@/components/UserCard";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
|
import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score";
|
||||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import {groupByExam} from "@/utils/stats";
|
import {groupByExam} from "@/utils/stats";
|
||||||
@@ -35,30 +38,158 @@ import GroupList from "@/pages/(admin)/Lists/GroupList";
|
|||||||
import useFilterStore from "@/stores/listFilterStore";
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import useCodes from "@/hooks/useCodes";
|
import useCodes from "@/hooks/useCodes";
|
||||||
|
import {getUserCorporate} from "@/utils/groups";
|
||||||
|
import useAssignments from "@/hooks/useAssignments";
|
||||||
|
import {Assignment} from "@/interfaces/results";
|
||||||
|
import AssignmentView from "./AssignmentView";
|
||||||
|
import AssignmentCreator from "./AssignmentCreator";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import AssignmentCard from "./AssignmentCard";
|
||||||
|
import {createColumnHelper} from "@tanstack/react-table";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
import List from "@/components/List";
|
||||||
|
import {getUserCompanyName} from "@/resources/user";
|
||||||
|
import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments";
|
||||||
|
import useUserBalance from "@/hooks/useUserBalance";
|
||||||
|
import AssignmentsPage from "./views/AssignmentsPage";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: CorporateUser;
|
user: CorporateUser;
|
||||||
|
linkedCorporate?: CorporateUser | MasterCorporateUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CorporateDashboard({user}: Props) {
|
type StudentPerformanceItem = User & {corporateName: string; group: string};
|
||||||
const [page, setPage] = useState("");
|
const StudentPerformanceList = ({items, stats, users}: {items: StudentPerformanceItem[]; stats: Stat[]; users: User[]}) => {
|
||||||
|
const [isShowingAmount, setIsShowingAmount] = useState(false);
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<StudentPerformanceItem>();
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
columnHelper.accessor("name", {
|
||||||
|
header: "Student Name",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("email", {
|
||||||
|
header: "E-mail",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("demographicInformation.passport_id", {
|
||||||
|
header: "ID",
|
||||||
|
cell: (info) => info.getValue() || "N/A",
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("group", {
|
||||||
|
header: "Group",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("corporateName", {
|
||||||
|
header: "Corporate",
|
||||||
|
cell: (info) => info.getValue() || "N/A",
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.reading", {
|
||||||
|
header: "Reading",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? info.getValue() || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.listening", {
|
||||||
|
header: "Listening",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? info.getValue() || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.writing", {
|
||||||
|
header: "Writing",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? info.getValue() || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.speaking", {
|
||||||
|
header: "Speaking",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? info.getValue() || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.level", {
|
||||||
|
header: "Level",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? info.getValue() || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels", {
|
||||||
|
id: "overall_level",
|
||||||
|
header: "Overall",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? averageLevelCalculator(
|
||||||
|
users,
|
||||||
|
stats.filter((x) => x.user === info.row.original.id),
|
||||||
|
).toFixed(1)
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 w-full h-full">
|
||||||
|
<Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}>
|
||||||
|
Show Utilization
|
||||||
|
</Checkbox>
|
||||||
|
<List<StudentPerformanceItem>
|
||||||
|
data={items.sort(
|
||||||
|
(a, b) =>
|
||||||
|
averageLevelCalculator(
|
||||||
|
users,
|
||||||
|
stats.filter((x) => x.user === b.id),
|
||||||
|
) -
|
||||||
|
averageLevelCalculator(
|
||||||
|
users,
|
||||||
|
stats.filter((x) => x.user === a.id),
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
columns={columns}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default function CorporateDashboard({user, linkedCorporate}: Props) {
|
||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
const {stats} = useStats();
|
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
||||||
const {users, reload} = useUsers();
|
const {groups} = useGroups({admin: user.id});
|
||||||
const {codes} = useCodes(user.id);
|
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
|
||||||
const {groups} = useGroups(user.id);
|
const {balance} = useUserBalance();
|
||||||
|
|
||||||
|
const {users: students, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(userHashStudent);
|
||||||
|
const {users: teachers, reload: reloadTeachers, isLoading: isTeachersLoading} = useUsers(userHashTeacher);
|
||||||
|
|
||||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]);
|
||||||
setShowModal(!!selectedUser && page === "");
|
|
||||||
}, [selectedUser, page]);
|
|
||||||
|
|
||||||
const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id);
|
const assignmentsUsers = useMemo(
|
||||||
const teacherFilter = (user: User) => user.type === "teacher" && groups.flatMap((g) => g.participants).includes(user.id);
|
() =>
|
||||||
|
[...teachers, ...students].filter((x) =>
|
||||||
|
!!selectedUser
|
||||||
|
? groups
|
||||||
|
.filter((g) => g.admin === selectedUser.id)
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(x.id) || false
|
||||||
|
: groups.flatMap((g) => g.participants).includes(x.id),
|
||||||
|
),
|
||||||
|
[groups, teachers, students, selectedUser],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShowModal(!!selectedUser && router.asPath === "/#");
|
||||||
|
}, [selectedUser, router.asPath]);
|
||||||
|
|
||||||
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
||||||
|
|
||||||
@@ -74,60 +205,6 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const StudentsList = () => {
|
|
||||||
const filter = (x: User) =>
|
|
||||||
x.type === "student" &&
|
|
||||||
(!!selectedUser
|
|
||||||
? groups
|
|
||||||
.filter((g) => g.admin === selectedUser.id)
|
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id) || false
|
|
||||||
: groups.flatMap((g) => g.participants).includes(x.id));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div
|
|
||||||
onClick={() => setPage("")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UserList user={user} filters={[filter]} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TeachersList = () => {
|
|
||||||
const filter = (x: User) =>
|
|
||||||
x.type === "teacher" &&
|
|
||||||
(!!selectedUser
|
|
||||||
? groups
|
|
||||||
.filter((g) => g.admin === selectedUser.id)
|
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id) || false
|
|
||||||
: groups.flatMap((g) => g.participants).includes(x.id));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div
|
|
||||||
onClick={() => setPage("")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UserList user={user} filters={[filter]} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const GroupsList = () => {
|
const GroupsList = () => {
|
||||||
const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id);
|
const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id);
|
||||||
|
|
||||||
@@ -135,7 +212,7 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
onClick={() => router.push("/")}
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
<BsArrowLeft className="text-xl" />
|
<BsArrowLeft className="text-xl" />
|
||||||
<span>Back</span>
|
<span>Back</span>
|
||||||
@@ -148,16 +225,54 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const StudentPerformancePage = () => {
|
||||||
|
const performanceStudents = students.map((u) => ({
|
||||||
|
...u,
|
||||||
|
group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A",
|
||||||
|
corporateName: getUserCompanyName(user, [], groups),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="w-full flex justify-between items-center">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={reloadStudents}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<span>Reload</span>
|
||||||
|
<BsArrowRepeat className={clsx("text-xl", isStudentsLoading && "animate-spin")} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StudentPerformanceList items={performanceStudents} stats={stats} users={students} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const averageLevelCalculator = (studentStats: Stat[]) => {
|
const averageLevelCalculator = (studentStats: Stat[]) => {
|
||||||
const formattedStats = studentStats
|
const formattedStats = studentStats
|
||||||
.map((s) => ({focus: users.find((u) => u.id === s.user)?.focus, score: s.score, module: s.module}))
|
.map((s) => ({
|
||||||
|
focus: students.find((u) => u.id === s.user)?.focus,
|
||||||
|
score: s.score,
|
||||||
|
module: s.module,
|
||||||
|
}))
|
||||||
.filter((f) => !!f.focus);
|
.filter((f) => !!f.focus);
|
||||||
const bandScores = formattedStats.map((s) => ({
|
const bandScores = formattedStats.map((s) => ({
|
||||||
module: s.module,
|
module: s.module,
|
||||||
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
|
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const levels: {[key in Module]: number} = {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0};
|
const levels: {[key in Module]: number} = {
|
||||||
|
reading: 0,
|
||||||
|
listening: 0,
|
||||||
|
writing: 0,
|
||||||
|
speaking: 0,
|
||||||
|
level: 0,
|
||||||
|
};
|
||||||
bandScores.forEach((b) => (levels[b.module] += b.level));
|
bandScores.forEach((b) => (levels[b.module] += b.level));
|
||||||
|
|
||||||
return calculateAverageLevel(levels);
|
return calculateAverageLevel(levels);
|
||||||
@@ -165,19 +280,26 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
|
|
||||||
const DefaultDashboard = () => (
|
const DefaultDashboard = () => (
|
||||||
<>
|
<>
|
||||||
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center">
|
{!!linkedCorporate && (
|
||||||
|
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
|
||||||
|
Linked to: <b>{linkedCorporate?.corporateInformation?.companyInformation.name || linkedCorporate.name}</b>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<section className="grid grid-cols-5 -md:grid-cols-2 gap-4 text-center">
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("students")}
|
onClick={() => router.push("/#students")}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
Icon={BsPersonFill}
|
Icon={BsPersonFill}
|
||||||
label="Students"
|
label="Students"
|
||||||
value={users.filter(studentFilter).length}
|
value={students.length}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("teachers")}
|
onClick={() => router.push("/#teachers")}
|
||||||
|
isLoading={isTeachersLoading}
|
||||||
Icon={BsPencilSquare}
|
Icon={BsPencilSquare}
|
||||||
label="Teachers"
|
label="Teachers"
|
||||||
value={users.filter(teacherFilter).length}
|
value={teachers.length}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
@@ -188,15 +310,16 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsPaperclip}
|
Icon={BsPaperclip}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
label="Average Level"
|
label="Average Level"
|
||||||
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard onClick={() => setPage("groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
|
<IconCard onClick={() => router.push("/#groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsPersonCheck}
|
Icon={BsPersonCheck}
|
||||||
label="User Balance"
|
label="User Balance"
|
||||||
value={`${codes.length}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
|
value={`${balance}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
@@ -205,14 +328,33 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
|
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
|
||||||
color="rose"
|
color="rose"
|
||||||
/>
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsPersonFillGear}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
|
label="Student Performance"
|
||||||
|
value={students.length}
|
||||||
|
color="purple"
|
||||||
|
onClick={() => router.push("/#studentsPerformance")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
disabled={isAssignmentsLoading}
|
||||||
|
onClick={() => router.push("/#assignments")}
|
||||||
|
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||||
|
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
||||||
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
|
<span className="text-lg">Assignments</span>
|
||||||
|
<span className="font-semibold text-mti-purple-light">
|
||||||
|
{isAssignmentsLoading ? "Loading..." : assignments.filter((a) => !a.archived).length}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Latest students</span>
|
<span className="p-4">Latest students</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{students
|
||||||
.filter(studentFilter)
|
|
||||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
@@ -222,8 +364,7 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Latest teachers</span>
|
<span className="p-4">Latest teachers</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{teachers
|
||||||
.filter(teacherFilter)
|
|
||||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
@@ -233,8 +374,7 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Highest level students</span>
|
<span className="p-4">Highest level students</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{students
|
||||||
.filter(studentFilter)
|
|
||||||
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
@@ -244,8 +384,7 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Highest exam count students</span>
|
<span className="p-4">Highest exam count students</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{students
|
||||||
.filter(studentFilter)
|
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
||||||
@@ -269,7 +408,8 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
loggedInUser={user}
|
loggedInUser={user}
|
||||||
onClose={(shouldReload) => {
|
onClose={(shouldReload) => {
|
||||||
setSelectedUser(undefined);
|
setSelectedUser(undefined);
|
||||||
if (shouldReload) reload();
|
if (shouldReload && selectedUser!.type === "student") reloadStudents();
|
||||||
|
if (shouldReload && selectedUser!.type === "teacher") reloadTeachers();
|
||||||
}}
|
}}
|
||||||
onViewStudents={
|
onViewStudents={
|
||||||
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
||||||
@@ -317,10 +457,54 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
</Modal>
|
</Modal>
|
||||||
{page === "students" && <StudentsList />}
|
{router.asPath === "/#students" && (
|
||||||
{page === "teachers" && <TeachersList />}
|
<UserList
|
||||||
{page === "groups" && <GroupsList />}
|
user={user}
|
||||||
{page === "" && <DefaultDashboard />}
|
type="student"
|
||||||
|
renderHeader={(total) => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Students ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{router.asPath === "/#teachers" && (
|
||||||
|
<UserList
|
||||||
|
user={user}
|
||||||
|
type="teacher"
|
||||||
|
renderHeader={(total) => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Teachers ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{router.asPath === "/#groups" && <GroupsList />}
|
||||||
|
{router.asPath === "/#assignments" && (
|
||||||
|
<AssignmentsPage
|
||||||
|
assignments={assignments}
|
||||||
|
user={user}
|
||||||
|
groups={assignmentsGroups}
|
||||||
|
users={assignmentsUsers}
|
||||||
|
reloadAssignments={reloadAssignments}
|
||||||
|
isLoading={isAssignmentsLoading}
|
||||||
|
onBack={() => router.push("/")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{router.asPath === "/#studentsPerformance" && <StudentPerformancePage />}
|
||||||
|
{router.asPath === "/" && <DefaultDashboard />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
96
src/dashboards/CorporateStudentsLevels.tsx
Normal file
96
src/dashboards/CorporateStudentsLevels.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import React, {useMemo} from "react";
|
||||||
|
import useUsers, { userHashStudent, userHashTeacher, userHashCorporate } from "@/hooks/useUsers";
|
||||||
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
import {User} from "@/interfaces/user";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
|
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||||
|
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||||
|
import {capitalize} from "lodash";
|
||||||
|
import {getLevelLabel} from "@/utils/score";
|
||||||
|
|
||||||
|
const Card = ({user}: {user: User}) => {
|
||||||
|
return (
|
||||||
|
<div className="border-mti-gray-platinum flex flex-col h-fit w-full cursor-pointer gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<h3 className="text-xl font-semibold">{user.name}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full gap-3 flex-wrap">
|
||||||
|
{MODULE_ARRAY.map((module) => {
|
||||||
|
const desiredLevel = user.desiredLevels[module] || 9;
|
||||||
|
const level = user.levels[module] || 0;
|
||||||
|
return (
|
||||||
|
<div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4 min-w-[250px]" key={module}>
|
||||||
|
<div className="flex items-center gap-2 md:gap-3">
|
||||||
|
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
|
||||||
|
{module === "reading" && <BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />}
|
||||||
|
{module === "listening" && <BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />}
|
||||||
|
{module === "writing" && <BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />}
|
||||||
|
{module === "speaking" && <BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />}
|
||||||
|
{module === "level" && <BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col">
|
||||||
|
<span className="text-sm font-bold md:font-extrabold w-full">{capitalize(module)}</span>
|
||||||
|
<div className="text-mti-gray-dim text-sm font-normal">
|
||||||
|
{module === "level" && <span>English Level: {getLevelLabel(level).join(" / ")}</span>}
|
||||||
|
{module !== "level" && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>Level {level} / Level 9</span>
|
||||||
|
<span>Desired Level: {desiredLevel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="md:pl-14">
|
||||||
|
<ProgressBar
|
||||||
|
color={module}
|
||||||
|
label=""
|
||||||
|
mark={Math.round((desiredLevel * 100) / 9)}
|
||||||
|
markLabel={`Desired Level: ${desiredLevel}`}
|
||||||
|
percentage={Math.round((level * 100) / 9)}
|
||||||
|
className="h-2 w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CorporateStudentsLevels = () => {
|
||||||
|
const [corporateId, setCorporateId] = React.useState<string>("");
|
||||||
|
|
||||||
|
const {users: students} = useUsers(userHashStudent);
|
||||||
|
const {users: corporates} = useUsers(userHashCorporate);
|
||||||
|
|
||||||
|
const corporate = useMemo(() => corporates.find((u) => u.id === corporateId) || corporates[0], [corporates, corporateId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
options={corporates.map((x: User) => ({
|
||||||
|
value: x.id,
|
||||||
|
label: `${x.name} - ${x.email}`,
|
||||||
|
}))}
|
||||||
|
value={corporate ? {value: corporate.id, label: corporate.name} : null}
|
||||||
|
onChange={(value) => setCorporateId(value?.value!)}
|
||||||
|
styles={{
|
||||||
|
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||||
|
option: (styles, state) => ({
|
||||||
|
...styles,
|
||||||
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
|
color: state.isFocused ? "black" : styles.color,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{students.map((u) => (
|
||||||
|
<Card user={u} key={u.id} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CorporateStudentsLevels;
|
||||||
@@ -4,17 +4,21 @@ import {IconType} from "react-icons";
|
|||||||
interface Props {
|
interface Props {
|
||||||
Icon: IconType;
|
Icon: IconType;
|
||||||
label: string;
|
label: string;
|
||||||
value: string | number;
|
value?: string | number;
|
||||||
color: "purple" | "rose" | "red";
|
color: "purple" | "rose" | "red" | "green";
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
isSelected?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function IconCard({Icon, label, value, color, tooltip, onClick}: Props) {
|
export default function IconCard({Icon, label, value, color, tooltip, onClick, className, isLoading, isSelected}: Props) {
|
||||||
const colorClasses: {[key in typeof color]: string} = {
|
const colorClasses: {[key in typeof color]: string} = {
|
||||||
purple: "text-mti-purple-light",
|
purple: "mti-purple-light",
|
||||||
red: "text-mti-red-light",
|
red: "mti-red-light",
|
||||||
rose: "text-mti-rose-light",
|
rose: "mti-rose-light",
|
||||||
|
green: "mti-green-light",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -23,12 +27,16 @@ export default function IconCard({Icon, label, value, color, tooltip, onClick}:
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center text-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300",
|
"bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center text-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300",
|
||||||
tooltip && "tooltip tooltip-bottom",
|
tooltip && "tooltip tooltip-bottom",
|
||||||
|
isSelected && `border border-solid border-${colorClasses[color]}`,
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
data-tip={tooltip}>
|
data-tip={tooltip}>
|
||||||
<Icon className={clsx("text-6xl", colorClasses[color])} />
|
<Icon className={clsx("text-6xl", `text-${colorClasses[color]}`)} />
|
||||||
<span className="flex flex-col gap-1 items-center text-xl">
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
<span className="text-lg">{label}</span>
|
<span className="text-lg">{label}</span>
|
||||||
<span className={clsx("font-semibold", colorClasses[color])}>{value}</span>
|
<span className={clsx("font-semibold", `text-${colorClasses[color]}`, isLoading && "animate-pulse")}>
|
||||||
|
{isLoading ? "..." : value}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
688
src/dashboards/MasterCorporate.tsx
Normal file
688
src/dashboards/MasterCorporate.tsx
Normal file
@@ -0,0 +1,688 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
|
import useUsers, { userHashStudent, userHashTeacher, userHashCorporate } from "@/hooks/useUsers";
|
||||||
|
import {CorporateUser, Group, MasterCorporateUser, Stat, User} from "@/interfaces/user";
|
||||||
|
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
|
import {dateSorter} from "@/utils";
|
||||||
|
import moment from "moment";
|
||||||
|
import {useEffect, useState, useMemo} from "react";
|
||||||
|
import {
|
||||||
|
BsArrowLeft,
|
||||||
|
BsClipboard2Data,
|
||||||
|
BsClock,
|
||||||
|
BsPaperclip,
|
||||||
|
BsPersonFill,
|
||||||
|
BsPencilSquare,
|
||||||
|
BsPersonCheck,
|
||||||
|
BsPeople,
|
||||||
|
BsBank,
|
||||||
|
BsEnvelopePaper,
|
||||||
|
BsArrowRepeat,
|
||||||
|
BsPlus,
|
||||||
|
BsPersonFillGear,
|
||||||
|
BsFilter,
|
||||||
|
BsDatabase,
|
||||||
|
} from "react-icons/bs";
|
||||||
|
import UserCard from "@/components/UserCard";
|
||||||
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
|
||||||
|
import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score";
|
||||||
|
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||||
|
import {Module} from "@/interfaces";
|
||||||
|
import {groupByExam} from "@/utils/stats";
|
||||||
|
import IconCard from "./IconCard";
|
||||||
|
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
||||||
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import useCodes from "@/hooks/useCodes";
|
||||||
|
import useAssignments from "@/hooks/useAssignments";
|
||||||
|
import {Assignment} from "@/interfaces/results";
|
||||||
|
import AssignmentView from "./AssignmentView";
|
||||||
|
import AssignmentCreator from "./AssignmentCreator";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import AssignmentCard from "./AssignmentCard";
|
||||||
|
import {createColumn, createColumnHelper} from "@tanstack/react-table";
|
||||||
|
import List from "@/components/List";
|
||||||
|
import {getUserCorporate} from "@/utils/groups";
|
||||||
|
import {getCorporateUser, getUserCompanyName} from "@/resources/user";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
import {groupBy, uniq, uniqBy} from "lodash";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
import {Menu, MenuButton, MenuItem, MenuItems} from "@headlessui/react";
|
||||||
|
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
|
||||||
|
import MasterStatistical from "./MasterStatistical";
|
||||||
|
import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments";
|
||||||
|
import useUserBalance from "@/hooks/useUserBalance";
|
||||||
|
import AssignmentsPage from "./views/AssignmentsPage";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: MasterCorporateUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StudentPerformanceItem = User & {
|
||||||
|
corporate?: CorporateUser;
|
||||||
|
group?: Group;
|
||||||
|
};
|
||||||
|
const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPerformanceItem[]; stats: Stat[]; users: User[]; groups: Group[]}) => {
|
||||||
|
const [isShowingAmount, setIsShowingAmount] = useState(false);
|
||||||
|
const [availableCorporates] = useState(
|
||||||
|
uniqBy(
|
||||||
|
items.map((x) => x.corporate),
|
||||||
|
"id",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const [availableGroups] = useState(
|
||||||
|
uniqBy(
|
||||||
|
items.map((x) => x.group),
|
||||||
|
"id",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [selectedCorporate, setSelectedCorporate] = useState<CorporateUser | null | undefined>(null);
|
||||||
|
const [selectedGroup, setSelectedGroup] = useState<Group | null | undefined>(null);
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<StudentPerformanceItem>();
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
columnHelper.accessor("name", {
|
||||||
|
header: "Student Name",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("email", {
|
||||||
|
header: "E-mail",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("demographicInformation.passport_id", {
|
||||||
|
header: "ID",
|
||||||
|
cell: (info) => info.getValue() || "N/A",
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("group", {
|
||||||
|
header: "Group",
|
||||||
|
cell: (info) => info.getValue()?.name || "N/A",
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("corporate", {
|
||||||
|
header: "Corporate",
|
||||||
|
cell: (info) => (!!info.getValue() ? getUserCompanyName(info.getValue() as User, users, groups) : "N/A"),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.reading", {
|
||||||
|
header: "Reading",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? calculateBandScore(
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "reading" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "reading" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.total, 0),
|
||||||
|
"level",
|
||||||
|
info.row.original.focus || "academic",
|
||||||
|
) || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.listening", {
|
||||||
|
header: "Listening",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? calculateBandScore(
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "listening" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "listening" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.total, 0),
|
||||||
|
"level",
|
||||||
|
info.row.original.focus || "academic",
|
||||||
|
) || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.writing", {
|
||||||
|
header: "Writing",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? calculateBandScore(
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "writing" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "writing" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.total, 0),
|
||||||
|
"level",
|
||||||
|
info.row.original.focus || "academic",
|
||||||
|
) || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.speaking", {
|
||||||
|
header: "Speaking",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? calculateBandScore(
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "speaking" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "speaking" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.total, 0),
|
||||||
|
"level",
|
||||||
|
info.row.original.focus || "academic",
|
||||||
|
) || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.level", {
|
||||||
|
header: "Level",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? calculateBandScore(
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "level" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "level" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.total, 0),
|
||||||
|
"level",
|
||||||
|
info.row.original.focus || "academic",
|
||||||
|
) || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels", {
|
||||||
|
id: "overall_level",
|
||||||
|
header: "Overall",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? averageLevelCalculator(
|
||||||
|
users,
|
||||||
|
stats.filter((x) => x.user === info.row.original.id),
|
||||||
|
).toFixed(1)
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const filterUsers = (data: StudentPerformanceItem[]) => {
|
||||||
|
const filterByCorporate = (item: StudentPerformanceItem) => item.corporate?.id === selectedCorporate?.id;
|
||||||
|
const filterByGroup = (item: StudentPerformanceItem) => item.group?.id === selectedGroup?.id;
|
||||||
|
|
||||||
|
const filters: ((item: StudentPerformanceItem) => boolean)[] = [];
|
||||||
|
if (selectedCorporate !== null) filters.push(filterByCorporate);
|
||||||
|
if (selectedGroup !== null) filters.push(filterByGroup);
|
||||||
|
|
||||||
|
return filters.reduce((d, f) => d.filter(f), data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 w-full h-full">
|
||||||
|
<div className="w-full flex gap-4 justify-between items-center">
|
||||||
|
<Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}>
|
||||||
|
Show Utilization
|
||||||
|
</Checkbox>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<div className="flex items-center justify-center p-2 hover:bg-neutral-300/50 rounded-full transition ease-in-out duration-300">
|
||||||
|
<BsFilter size={20} />
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-96">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<span className="font-bold text-lg">Filters</span>
|
||||||
|
<Select
|
||||||
|
options={availableCorporates.map((x) => ({
|
||||||
|
value: x?.id || "N/A",
|
||||||
|
label: x?.corporateInformation?.companyInformation?.name || x?.name || "N/A",
|
||||||
|
}))}
|
||||||
|
isClearable
|
||||||
|
value={
|
||||||
|
selectedCorporate === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
value: selectedCorporate?.id || "N/A",
|
||||||
|
label:
|
||||||
|
selectedCorporate?.corporateInformation?.companyInformation?.name ||
|
||||||
|
selectedCorporate?.name ||
|
||||||
|
"N/A",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
placeholder="Select a Corporate..."
|
||||||
|
onChange={(value) =>
|
||||||
|
!value
|
||||||
|
? setSelectedCorporate(null)
|
||||||
|
: setSelectedCorporate(
|
||||||
|
value.value === "N/A" ? undefined : availableCorporates.find((x) => x?.id === value.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
options={availableGroups.map((x) => ({
|
||||||
|
value: x?.id || "N/A",
|
||||||
|
label: x?.name || "N/A",
|
||||||
|
}))}
|
||||||
|
isClearable
|
||||||
|
value={
|
||||||
|
selectedGroup === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
value: selectedGroup?.id || "N/A",
|
||||||
|
label: selectedGroup?.name || "N/A",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
placeholder="Select a Group..."
|
||||||
|
onChange={(value) =>
|
||||||
|
!value
|
||||||
|
? setSelectedGroup(null)
|
||||||
|
: setSelectedGroup(value.value === "N/A" ? undefined : availableGroups.find((x) => x?.id === value.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
<List<StudentPerformanceItem>
|
||||||
|
data={filterUsers(
|
||||||
|
items.sort(
|
||||||
|
(a, b) =>
|
||||||
|
averageLevelCalculator(
|
||||||
|
users,
|
||||||
|
stats.filter((x) => x.user === b.id),
|
||||||
|
) -
|
||||||
|
averageLevelCalculator(
|
||||||
|
users,
|
||||||
|
stats.filter((x) => x.user === a.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
columns={columns}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MasterCorporateDashboard({user}: Props) {
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [corporateAssignments, setCorporateAssignments] = useState<(Assignment & {corporate?: CorporateUser})[]>([]);
|
||||||
|
|
||||||
|
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
||||||
|
|
||||||
|
const {users: students, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(userHashStudent);
|
||||||
|
const {users: teachers, reload: reloadTeachers, isLoading: isTeachersLoading} = useUsers(userHashTeacher);
|
||||||
|
const {users: corporates, reload: reloadCorporates, isLoading: isCorporatesLoading} = useUsers(userHashCorporate);
|
||||||
|
|
||||||
|
const {groups} = useGroups({admin: user.id, userType: user.type});
|
||||||
|
const {balance} = useUserBalance();
|
||||||
|
|
||||||
|
const users = useMemo(() => uniqBy([...students, ...teachers, ...corporates, user], "id"), [corporates, students, teachers, user]);
|
||||||
|
|
||||||
|
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
|
||||||
|
|
||||||
|
const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]);
|
||||||
|
const assignmentsUsers = useMemo(
|
||||||
|
() =>
|
||||||
|
[...students, ...teachers].filter((x) =>
|
||||||
|
!!selectedUser
|
||||||
|
? groups
|
||||||
|
.filter((g) => g.admin === selectedUser.id)
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(x.id) || false
|
||||||
|
: groups.flatMap((g) => g.participants).includes(x.id),
|
||||||
|
),
|
||||||
|
[groups, selectedUser, teachers, students],
|
||||||
|
);
|
||||||
|
|
||||||
|
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShowModal(!!selectedUser && router.asPath === "/");
|
||||||
|
}, [selectedUser, router.asPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCorporateAssignments(
|
||||||
|
assignments.filter(activeAssignmentFilter).map((a) => {
|
||||||
|
const assigner = [...teachers, ...corporates].find((x) => x.id === a.assigner);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...a,
|
||||||
|
corporate: assigner ? getCorporateUser(assigner, [...teachers, ...corporates], groups) : undefined,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [assignments, groups, teachers, corporates]);
|
||||||
|
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
||||||
|
|
||||||
|
const UserDisplay = (displayUser: User) => (
|
||||||
|
<div
|
||||||
|
onClick={() => setSelectedUser(displayUser)}
|
||||||
|
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
||||||
|
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
||||||
|
<div className="flex flex-col gap-1 items-start">
|
||||||
|
<span>{displayUser.name}</span>
|
||||||
|
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const corporateUserFilter = (x: User) => x.type === "corporate";
|
||||||
|
|
||||||
|
const GroupsList = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Groups ({groups.length})</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GroupList user={user} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StudentPerformancePage = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="w-full flex justify-between items-center">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={reloadAssignments}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<span>Reload</span>
|
||||||
|
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StudentPerformanceList items={students} stats={stats} users={corporates} groups={groups} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MasterStatisticalPage = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Master Statistical</h2>
|
||||||
|
</div>
|
||||||
|
<MasterStatistical users={users} corporateUsers={corporates} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DefaultDashboard = () => (
|
||||||
|
<>
|
||||||
|
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center">
|
||||||
|
<IconCard
|
||||||
|
onClick={() => router.push("/#students")}
|
||||||
|
Icon={BsPersonFill}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
|
label="Students"
|
||||||
|
value={students.length}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
onClick={() => router.push("/#teachers")}
|
||||||
|
Icon={BsPencilSquare}
|
||||||
|
isLoading={isTeachersLoading}
|
||||||
|
label="Teachers"
|
||||||
|
value={teachers.length}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsClipboard2Data}
|
||||||
|
label="Exams Performed"
|
||||||
|
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsPaperclip}
|
||||||
|
label="Average Level"
|
||||||
|
value={averageLevelCalculator(
|
||||||
|
students,
|
||||||
|
stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)),
|
||||||
|
).toFixed(1)}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard onClick={() => router.push("/#groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
|
||||||
|
<IconCard
|
||||||
|
Icon={BsPersonCheck}
|
||||||
|
label="User Balance"
|
||||||
|
value={`${balance}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsClock}
|
||||||
|
label="Expiration Date"
|
||||||
|
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
|
||||||
|
color="rose"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsBank}
|
||||||
|
label="Corporate"
|
||||||
|
value={corporates.length}
|
||||||
|
isLoading={isCorporatesLoading}
|
||||||
|
color="purple"
|
||||||
|
onClick={() => router.push("/#corporate")}
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsPersonFillGear}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
|
label="Student Performance"
|
||||||
|
value={students.length}
|
||||||
|
color="purple"
|
||||||
|
onClick={() => router.push("/#studentsPerformance")}
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsDatabase}
|
||||||
|
label="Master Statistical"
|
||||||
|
// value={masterCorporateUserGroups.length}
|
||||||
|
color="purple"
|
||||||
|
onClick={() => router.push("/#statistical")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
disabled={isAssignmentsLoading}
|
||||||
|
onClick={() => router.push("/#assignments")}
|
||||||
|
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||||
|
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
||||||
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
|
<span className="text-lg">Assignments</span>
|
||||||
|
<span className="font-semibold text-mti-purple-light">
|
||||||
|
{isAssignmentsLoading ? "Loading..." : assignments.filter((a) => !a.archived).length}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Latest students</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{students
|
||||||
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Latest teachers</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{teachers
|
||||||
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Highest level students</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{students
|
||||||
|
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Highest exam count students</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{students
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
||||||
|
)
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
|
||||||
|
<>
|
||||||
|
{selectedUser && (
|
||||||
|
<div className="w-full flex flex-col gap-8">
|
||||||
|
<UserCard
|
||||||
|
maxUserAmount={
|
||||||
|
user.type === "mastercorporate"
|
||||||
|
? (user.corporateInformation?.companyInformation?.userAmount || 0) - balance
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
loggedInUser={user}
|
||||||
|
onClose={(shouldReload) => {
|
||||||
|
setSelectedUser(undefined);
|
||||||
|
if (shouldReload && selectedUser!.type === "student") reloadStudents();
|
||||||
|
if (shouldReload && selectedUser!.type === "teacher") reloadTeachers();
|
||||||
|
if (shouldReload && selectedUser!.type === "corporate") reloadCorporates();
|
||||||
|
}}
|
||||||
|
onViewStudents={
|
||||||
|
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
||||||
|
? () => {
|
||||||
|
appendUserFilters({
|
||||||
|
id: "view-students",
|
||||||
|
filter: (x: User) => x.type === "student",
|
||||||
|
});
|
||||||
|
appendUserFilters({
|
||||||
|
id: "belongs-to-admin",
|
||||||
|
filter: (x: User) =>
|
||||||
|
groups
|
||||||
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(x.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push("/list/users");
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onViewTeachers={
|
||||||
|
selectedUser.type === "corporate" || selectedUser.type === "student"
|
||||||
|
? () => {
|
||||||
|
appendUserFilters({
|
||||||
|
id: "view-teachers",
|
||||||
|
filter: (x: User) => x.type === "teacher",
|
||||||
|
});
|
||||||
|
appendUserFilters({
|
||||||
|
id: "belongs-to-admin",
|
||||||
|
filter: (x: User) =>
|
||||||
|
groups
|
||||||
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(x.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push("/list/users");
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
user={selectedUser}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</Modal>
|
||||||
|
{router.asPath === "/#students" && (
|
||||||
|
<UserList
|
||||||
|
user={user}
|
||||||
|
type="student"
|
||||||
|
renderHeader={(total) => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Students ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{router.asPath === "/#teachers" && (
|
||||||
|
<UserList
|
||||||
|
user={user}
|
||||||
|
type="teacher"
|
||||||
|
renderHeader={(total) => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Teachers ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{router.asPath === "/#groups" && <GroupsList />}
|
||||||
|
{router.asPath === "/#corporate" && (
|
||||||
|
<UserList
|
||||||
|
user={user}
|
||||||
|
type="corporate"
|
||||||
|
renderHeader={(total) => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Corporate ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{router.asPath === "/#assignments" && (
|
||||||
|
<AssignmentsPage
|
||||||
|
assignments={assignments}
|
||||||
|
corporateAssignments={corporateAssignments}
|
||||||
|
groups={assignmentsGroups}
|
||||||
|
user={user}
|
||||||
|
users={assignmentsUsers}
|
||||||
|
reloadAssignments={reloadAssignments}
|
||||||
|
isLoading={isAssignmentsLoading}
|
||||||
|
onBack={() => router.push("/")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{router.asPath === "/#studentsPerformance" && <StudentPerformancePage />}
|
||||||
|
{router.asPath === "/#statistical" && <MasterStatisticalPage />}
|
||||||
|
{router.asPath === "/" && <DefaultDashboard />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
405
src/dashboards/MasterStatistical.tsx
Normal file
405
src/dashboards/MasterStatistical.tsx
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { CorporateUser, User } from "@/interfaces/user";
|
||||||
|
import { BsFileExcel, BsBank, BsPersonFill } from "react-icons/bs";
|
||||||
|
import IconCard from "./IconCard";
|
||||||
|
|
||||||
|
import useAssignmentsCorporates from "@/hooks/useAssignmentCorporates";
|
||||||
|
import ReactDatePicker from "react-datepicker";
|
||||||
|
|
||||||
|
import moment from "moment";
|
||||||
|
import { AssignmentWithCorporateId } from "@/interfaces/results";
|
||||||
|
import {
|
||||||
|
flexRender,
|
||||||
|
createColumnHelper,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
import { useListSearch } from "@/hooks/useListSearch";
|
||||||
|
import axios from "axios";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
interface Props {
|
||||||
|
corporateUsers: User[];
|
||||||
|
users: User[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableData {
|
||||||
|
user: string;
|
||||||
|
email: string;
|
||||||
|
correct: number;
|
||||||
|
corporate: string;
|
||||||
|
submitted: boolean;
|
||||||
|
date: moment.Moment;
|
||||||
|
assignment: string;
|
||||||
|
corporateId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserCount {
|
||||||
|
userCount: number;
|
||||||
|
maxUserCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchFilters = [["email"], ["user"], ["userId"]];
|
||||||
|
|
||||||
|
const MasterStatistical = (props: Props) => {
|
||||||
|
const { users, corporateUsers } = props;
|
||||||
|
|
||||||
|
const corporateRelevantUsers = React.useMemo(
|
||||||
|
() => corporateUsers.filter((x) => x.type !== "student") as CorporateUser[],
|
||||||
|
[corporateUsers]
|
||||||
|
);
|
||||||
|
|
||||||
|
const corporates = React.useMemo(
|
||||||
|
() => corporateRelevantUsers.map((x) => x.id),
|
||||||
|
[corporateRelevantUsers]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [selectedCorporates, setSelectedCorporates] =
|
||||||
|
React.useState<string[]>(corporates);
|
||||||
|
const [startDate, setStartDate] = React.useState<Date | null>(
|
||||||
|
moment("01/01/2023").toDate()
|
||||||
|
);
|
||||||
|
const [endDate, setEndDate] = React.useState<Date | null>(
|
||||||
|
moment().endOf("year").toDate()
|
||||||
|
);
|
||||||
|
|
||||||
|
const { assignments } = useAssignmentsCorporates({
|
||||||
|
// corporates: [...corporates, "tYU0HTiJdjMsS8SB7XJsUdMMP892"],
|
||||||
|
corporates: selectedCorporates,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [downloading, setDownloading] = React.useState<boolean>(false);
|
||||||
|
|
||||||
|
const tableResults = React.useMemo(
|
||||||
|
() =>
|
||||||
|
assignments.reduce((accmA: TableData[], a: AssignmentWithCorporateId) => {
|
||||||
|
const userResults = a.assignees.map((assignee) => {
|
||||||
|
const userStats =
|
||||||
|
a.results.find((r) => r.user === assignee)?.stats || [];
|
||||||
|
const userData = users.find((u) => u.id === assignee);
|
||||||
|
const corporate = users.find((u) => u.id === a.assigner)?.name || "";
|
||||||
|
const commonData = {
|
||||||
|
user: userData?.name || "",
|
||||||
|
email: userData?.email || "",
|
||||||
|
userId: assignee,
|
||||||
|
corporateId: a.corporateId,
|
||||||
|
corporate,
|
||||||
|
assignment: a.name,
|
||||||
|
};
|
||||||
|
if (userStats.length === 0) {
|
||||||
|
return {
|
||||||
|
...commonData,
|
||||||
|
correct: 0,
|
||||||
|
submitted: false,
|
||||||
|
// date: moment(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...commonData,
|
||||||
|
correct: userStats.reduce((n, e) => n + e.score.correct, 0),
|
||||||
|
submitted: true,
|
||||||
|
date: moment.max(userStats.map((e) => moment(e.date))),
|
||||||
|
};
|
||||||
|
}) as TableData[];
|
||||||
|
|
||||||
|
return [...accmA, ...userResults];
|
||||||
|
}, []),
|
||||||
|
[assignments, users]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getCorporateScores = (corporateId: string): UserCount => {
|
||||||
|
const corporateAssignmentsUsers = assignments
|
||||||
|
.filter((a) => a.corporateId === corporateId)
|
||||||
|
.reduce((acc, a) => acc + a.assignees.length, 0);
|
||||||
|
|
||||||
|
const corporateResults = tableResults.filter(
|
||||||
|
(r) => r.corporateId === corporateId
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
maxUserCount: corporateAssignmentsUsers,
|
||||||
|
userCount: corporateResults,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const corporateScores = corporates.reduce(
|
||||||
|
(accm, id) => ({
|
||||||
|
...accm,
|
||||||
|
[id]: getCorporateScores(id),
|
||||||
|
}),
|
||||||
|
{}
|
||||||
|
) as Record<string, UserCount>;
|
||||||
|
|
||||||
|
const consolidateScore = Object.values(corporateScores).reduce(
|
||||||
|
(acc: UserCount, { userCount, maxUserCount }: UserCount) => ({
|
||||||
|
userCount: acc.userCount + userCount,
|
||||||
|
maxUserCount: acc.maxUserCount + maxUserCount,
|
||||||
|
}),
|
||||||
|
{ userCount: 0, maxUserCount: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const getConsolidateScoreStr = (data: UserCount) =>
|
||||||
|
`${data.userCount}/${data.maxUserCount}`;
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<TableData>();
|
||||||
|
|
||||||
|
const defaultColumns = [
|
||||||
|
columnHelper.accessor("user", {
|
||||||
|
header: "User",
|
||||||
|
id: "user",
|
||||||
|
cell: (info) => {
|
||||||
|
return <span>{info.getValue()}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("email", {
|
||||||
|
header: "Email",
|
||||||
|
id: "email",
|
||||||
|
cell: (info) => {
|
||||||
|
return <span>{info.getValue()}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("corporate", {
|
||||||
|
header: "Corporate",
|
||||||
|
id: "corporate",
|
||||||
|
cell: (info) => {
|
||||||
|
return <span>{info.getValue()}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("assignment", {
|
||||||
|
header: "Assignment",
|
||||||
|
id: "assignment",
|
||||||
|
cell: (info) => {
|
||||||
|
return <span>{info.getValue()}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("submitted", {
|
||||||
|
header: "Submitted",
|
||||||
|
id: "submitted",
|
||||||
|
cell: (info) => {
|
||||||
|
return (
|
||||||
|
<Checkbox isChecked={info.getValue()} disabled onChange={() => {}}>
|
||||||
|
<span></span>
|
||||||
|
</Checkbox>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("correct", {
|
||||||
|
header: "Correct",
|
||||||
|
id: "correct",
|
||||||
|
cell: (info) => {
|
||||||
|
return <span>{info.getValue()}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("date", {
|
||||||
|
header: "Date",
|
||||||
|
id: "date",
|
||||||
|
cell: (info) => {
|
||||||
|
const date = info.getValue();
|
||||||
|
if (date) {
|
||||||
|
return <span>{date.format("DD/MM/YYYY")}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span>{""}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const {
|
||||||
|
rows: filteredRows,
|
||||||
|
renderSearch,
|
||||||
|
text: searchText,
|
||||||
|
} = useListSearch(searchFilters, tableResults);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: filteredRows,
|
||||||
|
columns: defaultColumns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const areAllSelected = selectedCorporates.length === corporates.length;
|
||||||
|
|
||||||
|
const getStudentsConsolidateScore = () => {
|
||||||
|
if (tableResults.length === 0) {
|
||||||
|
return { highest: null, lowest: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the student with the highest and lowest score
|
||||||
|
return tableResults.reduce(
|
||||||
|
(acc, curr) => {
|
||||||
|
if (curr.correct > acc.highest.correct) {
|
||||||
|
acc.highest = curr;
|
||||||
|
}
|
||||||
|
if (curr.correct < acc.lowest.correct) {
|
||||||
|
acc.lowest = curr;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ highest: tableResults[0], lowest: tableResults[0] }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerDownload = async () => {
|
||||||
|
try {
|
||||||
|
setDownloading(true);
|
||||||
|
const res = await axios.post("/api/assignments/statistical/excel", {
|
||||||
|
ids: selectedCorporates,
|
||||||
|
...(startDate ? { startDate: startDate.toISOString() } : {}),
|
||||||
|
...(endDate ? { endDate: endDate.toISOString() } : {}),
|
||||||
|
searchText,
|
||||||
|
});
|
||||||
|
toast.success("Report ready!");
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = res.data;
|
||||||
|
// download should have worked but there are some CORS issues
|
||||||
|
// https://firebase.google.com/docs/storage/web/download-files#cors_configuration
|
||||||
|
// link.download="report.pdf";
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noreferrer";
|
||||||
|
link.click();
|
||||||
|
setDownloading(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Failed to display the report!");
|
||||||
|
console.error(err);
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const consolidateResults = getStudentsConsolidateScore();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-2 items-center text-center">
|
||||||
|
<IconCard
|
||||||
|
Icon={BsBank}
|
||||||
|
label="Consolidate"
|
||||||
|
value={getConsolidateScoreStr(consolidateScore)}
|
||||||
|
color="purple"
|
||||||
|
onClick={() => {
|
||||||
|
if (areAllSelected) {
|
||||||
|
setSelectedCorporates([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedCorporates(corporates);
|
||||||
|
}}
|
||||||
|
isSelected={areAllSelected}
|
||||||
|
/>
|
||||||
|
{corporateRelevantUsers.map((group) => {
|
||||||
|
const isSelected = selectedCorporates.includes(group.id);
|
||||||
|
return (
|
||||||
|
<IconCard
|
||||||
|
key={group.id}
|
||||||
|
Icon={BsBank}
|
||||||
|
label={group.corporateInformation?.companyInformation?.name}
|
||||||
|
value={getConsolidateScoreStr(corporateScores[group.id])}
|
||||||
|
color="purple"
|
||||||
|
onClick={() => {
|
||||||
|
if (isSelected) {
|
||||||
|
setSelectedCorporates(
|
||||||
|
selectedCorporates.filter((x) => x !== group.id)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedCorporates([...selectedCorporates, group.id]);
|
||||||
|
}}
|
||||||
|
isSelected={isSelected}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 w-full">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Date
|
||||||
|
</label>
|
||||||
|
<ReactDatePicker
|
||||||
|
dateFormat="dd/MM/yyyy"
|
||||||
|
className="px-4 py-6 w-52 text-sm text-center font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
|
selected={startDate}
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
selectsRange
|
||||||
|
showMonthDropdown
|
||||||
|
onChange={([initialDate, finalDate]: [Date, Date]) => {
|
||||||
|
setStartDate(initialDate ?? moment("01/01/2023").toDate());
|
||||||
|
if (finalDate) {
|
||||||
|
// basicly selecting a final day works as if I'm selecting the first
|
||||||
|
// minute of that day. this way it covers the whole day
|
||||||
|
setEndDate(moment(finalDate).endOf("day").toDate());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEndDate(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{renderSearch()}
|
||||||
|
<div className="flex flex-col gap-3 justify-end">
|
||||||
|
<Button
|
||||||
|
className="max-w-[200px] h-[70px]"
|
||||||
|
variant="outline"
|
||||||
|
onClick={triggerDownload}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<table className="rounded-xl h-full bg-mti-purple-ultralight/40 w-full">
|
||||||
|
<thead>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<th className="p-4 text-left" key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody className="px-2">
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<tr
|
||||||
|
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
|
||||||
|
key={row.id}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td className="px-4 py-2" key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 items-center text-center">
|
||||||
|
{consolidateResults.highest && (
|
||||||
|
<IconCard
|
||||||
|
onClick={() => {}}
|
||||||
|
Icon={BsPersonFill}
|
||||||
|
label={`Highest result: ${consolidateResults.highest.user}`}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{consolidateResults.lowest && (
|
||||||
|
<IconCard
|
||||||
|
onClick={() => {}}
|
||||||
|
Icon={BsPersonFill}
|
||||||
|
label={`Lowest result: ${consolidateResults.lowest.user}`}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MasterStatistical;
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import InviteCard from "@/components/Medium/InviteCard";
|
import InviteCard from "@/components/Medium/InviteCard";
|
||||||
import PayPalPayment from "@/components/PayPalPayment";
|
|
||||||
import ProfileSummary from "@/components/ProfileSummary";
|
import ProfileSummary from "@/components/ProfileSummary";
|
||||||
import useAssignments from "@/hooks/useAssignments";
|
import useAssignments from "@/hooks/useAssignments";
|
||||||
|
import useGradingSystem from "@/hooks/useGrading";
|
||||||
import useInvites from "@/hooks/useInvites";
|
import useInvites from "@/hooks/useInvites";
|
||||||
import useStats from "@/hooks/useStats";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers, { userHashStudent, userHashTeacher, userHashCorporate} from "@/hooks/useUsers";
|
||||||
import {Invite} from "@/interfaces/invite";
|
import {Invite} from "@/interfaces/invite";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
import {CorporateUser, User} from "@/interfaces/user";
|
import {CorporateUser, MasterCorporateUser, Stat, User} from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {getExamById} from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
import {getUserCorporate} from "@/utils/groups";
|
import {getUserCorporate} from "@/utils/groups";
|
||||||
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
|
import {countExamModules, countFullExams, MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
|
||||||
import {getLevelLabel, getLevelScore} from "@/utils/score";
|
import {getGradingLabel, getLevelLabel, getLevelScore} from "@/utils/score";
|
||||||
import {averageScore, groupBySession} from "@/utils/stats";
|
import {averageScore, groupBySession} from "@/utils/stats";
|
||||||
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
|
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
|
||||||
import {PayPalButtons} from "@paypal/react-paypal-js";
|
import {PayPalButtons} from "@paypal/react-paypal-js";
|
||||||
@@ -24,22 +24,29 @@ import {capitalize} from "lodash";
|
|||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useMemo, useState} from "react";
|
||||||
import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs";
|
import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
|
import {activeAssignmentFilter} from "@/utils/assignments";
|
||||||
|
import ModuleBadge from "@/components/ModuleBadge";
|
||||||
|
import useSessions from "@/hooks/useSessions";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
|
linkedCorporate?: CorporateUser | MasterCorporateUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StudentDashboard({user}: Props) {
|
export default function StudentDashboard({user, linkedCorporate}: Props) {
|
||||||
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
|
const {gradingSystem} = useGradingSystem();
|
||||||
|
const {sessions} = useSessions(user.id);
|
||||||
const {stats} = useStats(user.id);
|
const {data: stats} = useFilterRecordsByUser<Stat[]>(user.id, !user?.id);
|
||||||
const {users} = useUsers();
|
|
||||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
|
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
|
||||||
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user.id});
|
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user.id});
|
||||||
|
|
||||||
|
const {users: teachers} = useUsers(userHashTeacher);
|
||||||
|
const {users: corporates} = useUsers(userHashCorporate);
|
||||||
|
|
||||||
|
const users = useMemo(() => [...teachers, ...corporates], [teachers, corporates]);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
const setExams = useExamStore((state) => state.setExams);
|
||||||
@@ -48,10 +55,6 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||||
const setAssignment = useExamStore((state) => state.setAssignment);
|
const setAssignment = useExamStore((state) => state.setAssignment);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getUserCorporate(user.id).then(setCorporateUserToShow);
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const startAssignment = (assignment: Assignment) => {
|
const startAssignment = (assignment: Assignment) => {
|
||||||
const examPromises = assignment.exams.filter((e) => e.assignee === user.id).map((e) => getExamById(e.module, e.id));
|
const examPromises = assignment.exams.filter((e) => e.assignee === user.id).map((e) => getExamById(e.module, e.id));
|
||||||
|
|
||||||
@@ -73,27 +76,29 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const studentAssignments = assignments.filter(activeAssignmentFilter);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{corporateUserToShow && (
|
{linkedCorporate && (
|
||||||
<div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1">
|
<div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1">
|
||||||
Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b>
|
Linked to: <b>{linkedCorporate?.corporateInformation?.companyInformation.name || linkedCorporate.name}</b>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ProfileSummary
|
<ProfileSummary
|
||||||
user={user}
|
user={user}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
icon: <BsFileEarmarkText className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||||
value: Object.keys(groupBySession(stats)).length,
|
value: countFullExams(stats),
|
||||||
label: "Exams",
|
label: "Exams",
|
||||||
tooltip: "Number of all conducted completed exams",
|
tooltip: "Number of all conducted completed exams",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsPencil className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||||
value: stats.length,
|
value: countExamModules(stats),
|
||||||
label: "Exercises",
|
label: "Modules",
|
||||||
tooltip: "Number of all conducted exercises including Level Test",
|
tooltip: "Number of all exam modules performed including Level Test",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
icon: <BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
||||||
@@ -123,50 +128,32 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||||
{assignments.filter((a) => moment(a.endDate).isSameOrAfter(moment())).length === 0 &&
|
{studentAssignments.length === 0 && "Assignments will appear here. It seems that for now there are no assignments for you."}
|
||||||
"Assignments will appear here. It seems that for now there are no assignments for you."}
|
{studentAssignments
|
||||||
{assignments
|
|
||||||
.filter((a) => moment(a.endDate).isSameOrAfter(moment()))
|
|
||||||
.sort((a, b) => moment(a.startDate).diff(b.startDate))
|
.sort((a, b) => moment(a.startDate).diff(b.startDate))
|
||||||
.map((assignment) => (
|
.map((assignment) => (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"border-mti-gray-anti-flash flex min-w-[300px] flex-col gap-6 rounded-xl border p-4",
|
"border-mti-gray-anti-flash flex min-w-[350px] flex-col gap-6 rounded-xl border p-4",
|
||||||
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light",
|
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light",
|
||||||
)}
|
)}
|
||||||
key={assignment.id}>
|
key={assignment.id}>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3>
|
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3>
|
||||||
<span className="flex justify-between gap-1">
|
<span className="flex justify-between gap-1 text-lg">
|
||||||
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
<div className="-md:mt-2 grid w-fit min-w-[104px] grid-cols-2 place-items-center justify-center gap-2">
|
<div className="-md:mt-2 grid w-fit min-w-[140px] grid-cols-2 grid-rows-2 place-items-center justify-between gap-4">
|
||||||
{assignment.exams
|
{assignment.exams
|
||||||
.filter((e) => e.assignee === user.id)
|
.filter((e) => e.assignee === user.id)
|
||||||
.map((e) => e.module)
|
.map((e) => e.module)
|
||||||
.sort(sortByModuleName)
|
.sort(sortByModuleName)
|
||||||
.map((module) => (
|
.map((module) => (
|
||||||
<div
|
<ModuleBadge className="scale-110 w-full" key={module} module={module} />
|
||||||
key={module}
|
|
||||||
data-tip={capitalize(module)}
|
|
||||||
className={clsx(
|
|
||||||
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
|
||||||
module === "reading" && "bg-ielts-reading",
|
|
||||||
module === "listening" && "bg-ielts-listening",
|
|
||||||
module === "writing" && "bg-ielts-writing",
|
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
|
||||||
module === "level" && "bg-ielts-level",
|
|
||||||
)}>
|
|
||||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
|
||||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
|
||||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
|
||||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!assignment.results.map((r) => r.user).includes(user.id) && (
|
{!assignment.results.map((r) => r.user).includes(user.id) && (
|
||||||
@@ -174,20 +161,24 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
<div
|
<div
|
||||||
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
|
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
|
||||||
data-tip="Your screen size is too small to perform an assignment">
|
data-tip="Your screen size is too small to perform an assignment">
|
||||||
<Button
|
<Button className="h-full w-full !rounded-xl" variant="outline">
|
||||||
disabled={moment(assignment.startDate).isAfter(moment())}
|
Start
|
||||||
className="h-full w-full !rounded-xl"
|
</Button>
|
||||||
variant="outline">
|
</div>
|
||||||
|
<div
|
||||||
|
data-tip="You have already started this assignment!"
|
||||||
|
className={clsx(
|
||||||
|
"-md:hidden h-full w-full max-w-[50%] cursor-pointer",
|
||||||
|
sessions.filter((x) => x.assignment?.id === assignment.id).length > 0 && "tooltip",
|
||||||
|
)}>
|
||||||
|
<Button
|
||||||
|
className={clsx("w-full h-full !rounded-xl")}
|
||||||
|
onClick={() => startAssignment(assignment)}
|
||||||
|
variant="outline"
|
||||||
|
disabled={sessions.filter((x) => x.assignment?.id === assignment.id).length > 0}>
|
||||||
Start
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
disabled={moment(assignment.startDate).isAfter(moment())}
|
|
||||||
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
|
||||||
onClick={() => startAssignment(assignment)}
|
|
||||||
variant="outline">
|
|
||||||
Start
|
|
||||||
</Button>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{assignment.results.map((r) => r.user).includes(user.id) && (
|
{assignment.results.map((r) => r.user).includes(user.id) && (
|
||||||
@@ -244,7 +235,7 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
<div className="flex w-full justify-between">
|
<div className="flex w-full justify-between">
|
||||||
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span>
|
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span>
|
||||||
<span className="text-mti-gray-dim text-sm font-normal">
|
<span className="text-mti-gray-dim text-sm font-normal">
|
||||||
{module === "level" && `English Level: ${getLevelLabel(level).join(" / ")}`}
|
{module === "level" && !!gradingSystem && `English Level: ${getGradingLabel(level, gradingSystem.steps)}`}
|
||||||
{module !== "level" && `Level ${level} / Level 9 (Desired Level: ${desiredLevel})`}
|
{module !== "level" && `Level ${level} / Level 9 (Desired Level: ${desiredLevel})`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,9 +244,9 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
<ProgressBar
|
<ProgressBar
|
||||||
color={module}
|
color={module}
|
||||||
label=""
|
label=""
|
||||||
mark={Math.round((desiredLevel * 100) / 9)}
|
mark={module === "level" ? undefined : Math.round((desiredLevel * 100) / 9)}
|
||||||
markLabel={`Desired Level: ${desiredLevel}`}
|
markLabel={`Desired Level: ${desiredLevel}`}
|
||||||
percentage={Math.round((level * 100) / 9)}
|
percentage={module === "level" ? level : Math.round((level * 100) / 9)}
|
||||||
className="h-2 w-full"
|
className="h-2 w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useStats from "@/hooks/useStats";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers, { userHashStudent, userHashTeacher, userHashCorporate } from "@/hooks/useUsers";
|
||||||
import {CorporateUser, Group, Stat, User} from "@/interfaces/user";
|
import {CorporateUser, Group, MasterCorporateUser, Stat, User} from "@/interfaces/user";
|
||||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
import {dateSorter} from "@/utils";
|
import {dateSorter} from "@/utils";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useMemo, useState} from "react";
|
||||||
import {
|
import {
|
||||||
BsArrowLeft,
|
BsArrowLeft,
|
||||||
BsArrowRepeat,
|
BsArrowRepeat,
|
||||||
@@ -46,33 +46,50 @@ import ProgressBar from "@/components/Low/ProgressBar";
|
|||||||
import AssignmentCreator from "./AssignmentCreator";
|
import AssignmentCreator from "./AssignmentCreator";
|
||||||
import AssignmentView from "./AssignmentView";
|
import AssignmentView from "./AssignmentView";
|
||||||
import {getUserCorporate} from "@/utils/groups";
|
import {getUserCorporate} from "@/utils/groups";
|
||||||
|
import {checkAccess} from "@/utils/permissions";
|
||||||
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments";
|
||||||
|
import AssignmentsPage from "./views/AssignmentsPage";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
|
linkedCorporate?: CorporateUser | MasterCorporateUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TeacherDashboard({user}: Props) {
|
export default function TeacherDashboard({user, linkedCorporate}: Props) {
|
||||||
const [page, setPage] = useState("");
|
|
||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
|
|
||||||
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
|
||||||
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
|
|
||||||
|
|
||||||
const {stats} = useStats();
|
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
||||||
const {users, reload} = useUsers();
|
const {groups} = useGroups({adminAdmins: user.id});
|
||||||
const {groups} = useGroups(user.id);
|
const {permissions} = usePermissions(user.id);
|
||||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id});
|
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id});
|
||||||
|
|
||||||
useEffect(() => {
|
const {users: students, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(userHashStudent);
|
||||||
setShowModal(!!selectedUser && page === "");
|
|
||||||
}, [selectedUser, page]);
|
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]);
|
||||||
|
|
||||||
|
const assignmentsUsers = useMemo(
|
||||||
|
() =>
|
||||||
|
students.filter((x) =>
|
||||||
|
!!selectedUser
|
||||||
|
? groups
|
||||||
|
.filter((g) => g.admin === selectedUser.id)
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(x.id)
|
||||||
|
: groups.flatMap((g) => g.participants).includes(x.id),
|
||||||
|
),
|
||||||
|
[groups, students, selectedUser],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getUserCorporate(user.id).then(setCorporateUserToShow);
|
setShowModal(!!selectedUser && router.asPath === "/#");
|
||||||
}, [user]);
|
}, [selectedUser, router.asPath]);
|
||||||
|
|
||||||
const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id);
|
|
||||||
|
|
||||||
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
||||||
|
|
||||||
@@ -88,41 +105,14 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const StudentsList = () => {
|
|
||||||
const filter = (x: User) =>
|
|
||||||
x.type === "student" &&
|
|
||||||
(!!selectedUser
|
|
||||||
? groups
|
|
||||||
.filter((g) => g.admin === selectedUser.id)
|
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id) || false
|
|
||||||
: groups.flatMap((g) => g.participants).includes(x.id));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div
|
|
||||||
onClick={() => setPage("")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UserList user={user} filters={[filter]} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const GroupsList = () => {
|
const GroupsList = () => {
|
||||||
const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id);
|
const filter = (x: Group) => x.admin === user.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
onClick={() => router.push("/")}
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
<BsArrowLeft className="text-xl" />
|
<BsArrowLeft className="text-xl" />
|
||||||
<span>Back</span>
|
<span>Back</span>
|
||||||
@@ -137,128 +127,47 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
|
|
||||||
const averageLevelCalculator = (studentStats: Stat[]) => {
|
const averageLevelCalculator = (studentStats: Stat[]) => {
|
||||||
const formattedStats = studentStats
|
const formattedStats = studentStats
|
||||||
.map((s) => ({focus: users.find((u) => u.id === s.user)?.focus, score: s.score, module: s.module}))
|
.map((s) => ({
|
||||||
|
focus: students.find((u) => u.id === s.user)?.focus,
|
||||||
|
score: s.score,
|
||||||
|
module: s.module,
|
||||||
|
}))
|
||||||
.filter((f) => !!f.focus);
|
.filter((f) => !!f.focus);
|
||||||
const bandScores = formattedStats.map((s) => ({
|
const bandScores = formattedStats.map((s) => ({
|
||||||
module: s.module,
|
module: s.module,
|
||||||
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
|
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const levels: {[key in Module]: number} = {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0};
|
const levels: {[key in Module]: number} = {
|
||||||
|
reading: 0,
|
||||||
|
listening: 0,
|
||||||
|
writing: 0,
|
||||||
|
speaking: 0,
|
||||||
|
level: 0,
|
||||||
|
};
|
||||||
bandScores.forEach((b) => (levels[b.module] += b.level));
|
bandScores.forEach((b) => (levels[b.module] += b.level));
|
||||||
|
|
||||||
return calculateAverageLevel(levels);
|
return calculateAverageLevel(levels);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AssignmentsPage = () => {
|
|
||||||
const activeFilter = (a: Assignment) => moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length;
|
|
||||||
const pastFilter = (a: Assignment) => (moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length) && !a.archived;
|
|
||||||
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AssignmentView
|
|
||||||
isOpen={!!selectedAssignment && !isCreatingAssignment}
|
|
||||||
onClose={() => {
|
|
||||||
setSelectedAssignment(undefined);
|
|
||||||
setIsCreatingAssignment(false);
|
|
||||||
reloadAssignments();
|
|
||||||
}}
|
|
||||||
assignment={selectedAssignment}
|
|
||||||
/>
|
|
||||||
<AssignmentCreator
|
|
||||||
assignment={selectedAssignment}
|
|
||||||
groups={groups.filter((x) => x.admin === user.id || x.participants.includes(user.id))}
|
|
||||||
users={users.filter(
|
|
||||||
(x) =>
|
|
||||||
x.type === "student" &&
|
|
||||||
(!!selectedUser
|
|
||||||
? groups
|
|
||||||
.filter((g) => g.admin === selectedUser.id)
|
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id) || false
|
|
||||||
: groups.flatMap((g) => g.participants).includes(x.id)),
|
|
||||||
)}
|
|
||||||
assigner={user.id}
|
|
||||||
isCreating={isCreatingAssignment}
|
|
||||||
cancelCreation={() => {
|
|
||||||
setIsCreatingAssignment(false);
|
|
||||||
setSelectedAssignment(undefined);
|
|
||||||
reloadAssignments();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="w-full flex justify-between items-center">
|
|
||||||
<div
|
|
||||||
onClick={() => setPage("")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
onClick={reloadAssignments}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<span>Reload</span>
|
|
||||||
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeFilter).length})</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{assignments.filter(activeFilter).map((a) => (
|
|
||||||
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureFilter).length})</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<div
|
|
||||||
onClick={() => setIsCreatingAssignment(true)}
|
|
||||||
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
|
|
||||||
<BsPlus className="text-6xl" />
|
|
||||||
<span className="text-lg">New Assignment</span>
|
|
||||||
</div>
|
|
||||||
{assignments.filter(futureFilter).map((a) => (
|
|
||||||
<AssignmentCard
|
|
||||||
{...a}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedAssignment(a);
|
|
||||||
setIsCreatingAssignment(true);
|
|
||||||
}}
|
|
||||||
key={a.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{assignments.filter(pastFilter).map((a) => (
|
|
||||||
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} allowDownload reload={reloadAssignments} allowArchive/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const DefaultDashboard = () => (
|
const DefaultDashboard = () => (
|
||||||
<>
|
<>
|
||||||
{corporateUserToShow && (
|
{linkedCorporate && (
|
||||||
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
|
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
|
||||||
Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b>
|
Linked to: <b>{linkedCorporate?.corporateInformation?.companyInformation.name || linkedCorporate.name}</b>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<section
|
<section
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center",
|
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center",
|
||||||
!!corporateUserToShow && "mt-12 xl:mt-6",
|
!!linkedCorporate && "mt-12 xl:mt-6",
|
||||||
)}>
|
)}>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("students")}
|
onClick={() => router.push("/#students")}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
Icon={BsPersonFill}
|
Icon={BsPersonFill}
|
||||||
label="Students"
|
label="Students"
|
||||||
value={users.filter(studentFilter).length}
|
value={students.length}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
@@ -270,12 +179,21 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsPaperclip}
|
Icon={BsPaperclip}
|
||||||
label="Average Level"
|
label="Average Level"
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard Icon={BsPeople} label="Groups" value={groups.length} color="purple" onClick={() => setPage("groups")} />
|
{checkAccess(user, ["teacher", "developer"], permissions, "viewGroup") && (
|
||||||
|
<IconCard
|
||||||
|
Icon={BsPeople}
|
||||||
|
label="Groups"
|
||||||
|
value={groups.filter((x) => x.admin === user.id).length}
|
||||||
|
color="purple"
|
||||||
|
onClick={() => router.push("/#groups")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("assignments")}
|
onClick={() => router.push("/#assignments")}
|
||||||
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||||
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
||||||
<span className="flex flex-col gap-1 items-center text-xl">
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
@@ -289,8 +207,7 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Latest students</span>
|
<span className="p-4">Latest students</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{students
|
||||||
.filter(studentFilter)
|
|
||||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
@@ -300,8 +217,7 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Highest level students</span>
|
<span className="p-4">Highest level students</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{students
|
||||||
.filter(studentFilter)
|
|
||||||
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
@@ -311,8 +227,7 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Highest exam count students</span>
|
<span className="p-4">Highest exam count students</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{students
|
||||||
.filter(studentFilter)
|
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
||||||
@@ -336,22 +251,84 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
loggedInUser={user}
|
loggedInUser={user}
|
||||||
onClose={(shouldReload) => {
|
onClose={(shouldReload) => {
|
||||||
setSelectedUser(undefined);
|
setSelectedUser(undefined);
|
||||||
if (shouldReload) reload();
|
if (shouldReload && selectedUser!.type === "student") reloadStudents();
|
||||||
}}
|
}}
|
||||||
onViewStudents={
|
onViewStudents={
|
||||||
selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
|
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
||||||
|
? () => {
|
||||||
|
appendUserFilters({
|
||||||
|
id: "view-students",
|
||||||
|
filter: (x: User) => x.type === "student",
|
||||||
|
});
|
||||||
|
appendUserFilters({
|
||||||
|
id: "belongs-to-admin",
|
||||||
|
filter: (x: User) =>
|
||||||
|
groups
|
||||||
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(x.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push("/list/users");
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onViewTeachers={
|
||||||
|
selectedUser.type === "corporate" || selectedUser.type === "student"
|
||||||
|
? () => {
|
||||||
|
appendUserFilters({
|
||||||
|
id: "view-teachers",
|
||||||
|
filter: (x: User) => x.type === "teacher",
|
||||||
|
});
|
||||||
|
appendUserFilters({
|
||||||
|
id: "belongs-to-admin",
|
||||||
|
filter: (x: User) =>
|
||||||
|
groups
|
||||||
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(x.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push("/list/users");
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
|
|
||||||
user={selectedUser}
|
user={selectedUser}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
</Modal>
|
</Modal>
|
||||||
{page === "students" && <StudentsList />}
|
{router.asPath === "/#students" && (
|
||||||
{page === "groups" && <GroupsList />}
|
<UserList
|
||||||
{page === "assignments" && <AssignmentsPage />}
|
user={user}
|
||||||
{page === "" && <DefaultDashboard />}
|
type="student"
|
||||||
|
renderHeader={(total) => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Students ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{router.asPath === "/#groups" && <GroupsList />}
|
||||||
|
{router.asPath === "/#assignments" && (
|
||||||
|
<AssignmentsPage
|
||||||
|
assignments={assignments}
|
||||||
|
groups={assignmentsGroups}
|
||||||
|
users={assignmentsUsers}
|
||||||
|
user={user}
|
||||||
|
reloadAssignments={reloadAssignments}
|
||||||
|
isLoading={isAssignmentsLoading}
|
||||||
|
onBack={() => router.push("/")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{router.asPath === "/" && <DefaultDashboard />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
233
src/dashboards/views/AssignmentsPage.tsx
Normal file
233
src/dashboards/views/AssignmentsPage.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import { Assignment } from "@/interfaces/results";
|
||||||
|
import { CorporateUser, Group, User } from "@/interfaces/user";
|
||||||
|
import { getUserCompanyName } from "@/resources/user";
|
||||||
|
import {
|
||||||
|
activeAssignmentFilter,
|
||||||
|
archivedAssignmentFilter,
|
||||||
|
futureAssignmentFilter,
|
||||||
|
pastAssignmentFilter,
|
||||||
|
startHasExpiredAssignmentFilter,
|
||||||
|
} from "@/utils/assignments";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { groupBy } from "lodash";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { BsArrowLeft, BsArrowRepeat, BsPlus } from "react-icons/bs";
|
||||||
|
import AssignmentCard from "../AssignmentCard";
|
||||||
|
import AssignmentCreator from "../AssignmentCreator";
|
||||||
|
import AssignmentView from "../AssignmentView";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
assignments: Assignment[];
|
||||||
|
corporateAssignments?: ({ corporate?: CorporateUser } & Assignment)[];
|
||||||
|
groups: Group[];
|
||||||
|
users: User[];
|
||||||
|
isLoading: boolean;
|
||||||
|
user: User;
|
||||||
|
onBack: () => void;
|
||||||
|
reloadAssignments: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssignmentsPage({
|
||||||
|
assignments,
|
||||||
|
corporateAssignments,
|
||||||
|
user,
|
||||||
|
groups,
|
||||||
|
users,
|
||||||
|
isLoading,
|
||||||
|
onBack,
|
||||||
|
reloadAssignments,
|
||||||
|
}: Props) {
|
||||||
|
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
|
||||||
|
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
||||||
|
|
||||||
|
const displayAssignmentView = !!selectedAssignment && !isCreatingAssignment;
|
||||||
|
|
||||||
|
const assignmentsPastExpiredStart = assignments.filter(startHasExpiredAssignmentFilter);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{displayAssignmentView && (
|
||||||
|
<AssignmentView
|
||||||
|
isOpen={displayAssignmentView}
|
||||||
|
onClose={() => {
|
||||||
|
setSelectedAssignment(undefined);
|
||||||
|
setIsCreatingAssignment(false);
|
||||||
|
reloadAssignments();
|
||||||
|
}}
|
||||||
|
assignment={selectedAssignment}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/** I'll be using this is creating assingment as a workaround for a key to trigger a new rendering */}
|
||||||
|
{isCreatingAssignment && (
|
||||||
|
<AssignmentCreator
|
||||||
|
assignment={selectedAssignment}
|
||||||
|
groups={groups}
|
||||||
|
users={users}
|
||||||
|
user={user}
|
||||||
|
isCreating={isCreatingAssignment}
|
||||||
|
cancelCreation={() => {
|
||||||
|
setIsCreatingAssignment(false);
|
||||||
|
setSelectedAssignment(undefined);
|
||||||
|
reloadAssignments();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="w-full flex justify-between items-center">
|
||||||
|
<div
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={reloadAssignments}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
|
<span>Reload</span>
|
||||||
|
<BsArrowRepeat
|
||||||
|
className={clsx("text-xl", isLoading && "animate-spin")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-lg font-bold">Active Assignments Status</span>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span>
|
||||||
|
<b>Total:</b>{" "}
|
||||||
|
{assignments
|
||||||
|
.filter(activeAssignmentFilter)
|
||||||
|
.reduce((acc, curr) => acc + curr.results.length, 0)}
|
||||||
|
/
|
||||||
|
{assignments
|
||||||
|
.filter(activeAssignmentFilter)
|
||||||
|
.reduce((acc, curr) => curr.exams.length + acc, 0)}
|
||||||
|
</span>
|
||||||
|
{Object.keys(
|
||||||
|
groupBy(corporateAssignments, (x) => x.corporate?.id)
|
||||||
|
).map((x) => (
|
||||||
|
<div key={x}>
|
||||||
|
<span className="font-semibold">
|
||||||
|
{getUserCompanyName(
|
||||||
|
users.find((u) => u.id === x)!,
|
||||||
|
users,
|
||||||
|
groups
|
||||||
|
)}
|
||||||
|
:{" "}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{groupBy(corporateAssignments, (x) => x.corporate?.id)[
|
||||||
|
x
|
||||||
|
].reduce((acc, curr) => curr.results.length + acc, 0)}
|
||||||
|
/
|
||||||
|
{groupBy(corporateAssignments, (x) => x.corporate?.id)[
|
||||||
|
x
|
||||||
|
].reduce((acc, curr) => curr.exams.length + acc, 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-2xl font-semibold">
|
||||||
|
Active Assignments (
|
||||||
|
{assignments.filter(activeAssignmentFilter).length})
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{assignments.filter(activeAssignmentFilter).map((a) => (
|
||||||
|
<AssignmentCard
|
||||||
|
{...a}
|
||||||
|
users={users}
|
||||||
|
onClick={() => setSelectedAssignment(a)}
|
||||||
|
key={a.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-2xl font-semibold">
|
||||||
|
Planned Assignments (
|
||||||
|
{assignments.filter(futureAssignmentFilter).length})
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<div
|
||||||
|
onClick={() => setIsCreatingAssignment(true)}
|
||||||
|
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
|
<BsPlus className="text-6xl" />
|
||||||
|
<span className="text-lg">New Assignment</span>
|
||||||
|
</div>
|
||||||
|
{assignments.filter(futureAssignmentFilter).map((a) => (
|
||||||
|
<AssignmentCard
|
||||||
|
{...a}
|
||||||
|
users={users}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedAssignment(a);
|
||||||
|
setIsCreatingAssignment(true);
|
||||||
|
}}
|
||||||
|
key={a.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-2xl font-semibold">
|
||||||
|
Past Assignments ({assignments.filter(pastAssignmentFilter).length})
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{assignments.filter(pastAssignmentFilter).map((a) => (
|
||||||
|
<AssignmentCard
|
||||||
|
{...a}
|
||||||
|
users={users}
|
||||||
|
onClick={() => setSelectedAssignment(a)}
|
||||||
|
key={a.id}
|
||||||
|
allowDownload
|
||||||
|
reload={reloadAssignments}
|
||||||
|
allowArchive
|
||||||
|
allowExcelDownload
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-2xl font-semibold">
|
||||||
|
Assignments start expired ({assignmentsPastExpiredStart.length})
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{assignments.filter(startHasExpiredAssignmentFilter).map((a) => (
|
||||||
|
<AssignmentCard
|
||||||
|
{...a}
|
||||||
|
users={users}
|
||||||
|
onClick={() => setSelectedAssignment(a)}
|
||||||
|
key={a.id}
|
||||||
|
allowDownload
|
||||||
|
reload={reloadAssignments}
|
||||||
|
allowArchive
|
||||||
|
allowExcelDownload
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-2xl font-semibold">
|
||||||
|
Archived Assignments (
|
||||||
|
{assignments.filter(archivedAssignmentFilter).length})
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{assignments.filter(archivedAssignmentFilter).map((a) => (
|
||||||
|
<AssignmentCard
|
||||||
|
{...a}
|
||||||
|
users={users}
|
||||||
|
onClick={() => setSelectedAssignment(a)}
|
||||||
|
key={a.id}
|
||||||
|
allowDownload
|
||||||
|
reload={reloadAssignments}
|
||||||
|
allowUnarchive
|
||||||
|
allowExcelDownload
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<br />
|
<br />
|
||||||
<p>Don't forget to do it before its end date!</p>
|
<p>Don't forget to do it before its end date!</p>
|
||||||
<p>Click <b><a href="https://platform.encoach.com">here</a></b> to open the EnCoach Platform!</p>
|
<p>Click <b><a href="https://{{environment}}.encoach.com">here</a></b> to open the EnCoach Platform!</p>
|
||||||
<br />
|
<br />
|
||||||
<p>Thanks,</p>
|
<p>Thanks,</p>
|
||||||
<p>Your EnCoach team</p>
|
<p>Your EnCoach team</p>
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
<img src="/logo_title.png" class="w-48 h-48 self-center" />
|
<img src="/logo_title.png" class="w-48 h-48 self-center" />
|
||||||
<div>
|
<div>
|
||||||
<span>Hello future {{type}} of <b>EnCoach</b>,</span><br />
|
<span>Hello future {{type}} of <b>EnCoach</b>,</span><br />
|
||||||
<span>You have been invited to register at <a href="https://platform.encoach.com/register?code={{code}}">EnCoach</a>
|
<span>You have been invited to register at <a
|
||||||
|
href="https://{{environment}}.encoach.com/register?code={{code}}">EnCoach</a>
|
||||||
to
|
to
|
||||||
become a
|
become a
|
||||||
{{type}}!</span><br />
|
{{type}}!</span><br />
|
||||||
@@ -19,7 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
<a href="https://platform.encoach.com/register?code={{code}}"></a>
|
<a href="https://{{environment}}.encoach.com/register?code={{code}}"></a>
|
||||||
<span class="self-center p-4 px-12 text-lg text-[#]" style="background-color: #D5D9F0; color: #353338">
|
<span class="self-center p-4 px-12 text-lg text-[#]" style="background-color: #D5D9F0; color: #353338">
|
||||||
<b>{{code}}</b>
|
<b>{{code}}</b>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
<p>Hello {{name}},</p>
|
<p>Hello {{name}},</p>
|
||||||
<br />
|
<br />
|
||||||
<p>Follow this link to verify your email address.</p>
|
<p>Follow this link to verify your email address.</p>
|
||||||
<a href="https://platform.encoach.com/action?mode=signIn&continueUrl={{email}}&oobCode={{code}}">Verify account</a>
|
<a href="https://{{environment}}.encoach.com/action?mode=signIn&continueUrl={{email}}&oobCode={{code}}">Verify
|
||||||
|
account</a>
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
<p>If you didn’t ask to verify this address, you can ignore this email.</p>
|
<p>If you didn’t ask to verify this address, you can ignore this email.</p>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Tiago Ribeiro",
|
"name": "Tiago Ribeiro",
|
||||||
"email": "tiago.ribeiro@ecrop.dev",
|
"email": "tiago.ribeiro@ecrop.dev",
|
||||||
"code": "123"
|
"code": "123",
|
||||||
|
"environment": "platform"
|
||||||
}
|
}
|
||||||
@@ -4,14 +4,31 @@ import {moduleResultText} from "@/constants/ielts";
|
|||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {calculateBandScore} from "@/utils/score";
|
import {calculateBandScore, getGradingLabel} from "@/utils/score";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {Fragment, useEffect, useState} from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs";
|
import {
|
||||||
|
BsArrowCounterclockwise,
|
||||||
|
BsBan,
|
||||||
|
BsBook,
|
||||||
|
BsClipboard,
|
||||||
|
BsClipboardFill,
|
||||||
|
BsEyeFill,
|
||||||
|
BsHeadphones,
|
||||||
|
BsMegaphone,
|
||||||
|
BsPen,
|
||||||
|
BsShareFill,
|
||||||
|
} from "react-icons/bs";
|
||||||
import {LevelScore} from "@/constants/ielts";
|
import {LevelScore} from "@/constants/ielts";
|
||||||
import {getLevelScore} from "@/utils/score";
|
import {getLevelScore} from "@/utils/score";
|
||||||
|
import {capitalize} from "lodash";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import {UserSolution} from "@/interfaces/exam";
|
||||||
|
import ai_usage from "@/utils/ai.detection";
|
||||||
|
import useGradingSystem from "@/hooks/useGrading";
|
||||||
|
import {Assignment} from "@/interfaces/results";
|
||||||
|
|
||||||
interface Score {
|
interface Score {
|
||||||
module: Module;
|
module: Module;
|
||||||
@@ -24,15 +41,24 @@ interface Props {
|
|||||||
user: User;
|
user: User;
|
||||||
modules: Module[];
|
modules: Module[];
|
||||||
scores: Score[];
|
scores: Score[];
|
||||||
|
information: {
|
||||||
|
timeSpent?: number;
|
||||||
|
inactivity?: number;
|
||||||
|
};
|
||||||
|
solutions: UserSolution[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onViewResults: () => void;
|
assignment?: Assignment;
|
||||||
|
onViewResults: (moduleIndex?: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Finish({user, scores, modules, isLoading, onViewResults}: Props) {
|
export default function Finish({user, scores, modules, information, solutions, isLoading, assignment, onViewResults}: Props) {
|
||||||
const [selectedModule, setSelectedModule] = useState(modules[0]);
|
const [selectedModule, setSelectedModule] = useState(modules[0]);
|
||||||
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
|
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
|
||||||
|
const [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false);
|
||||||
|
|
||||||
|
const aiUsage = Math.round(ai_usage(solutions) * 100);
|
||||||
const exams = useExamStore((state) => state.exams);
|
const exams = useExamStore((state) => state.exams);
|
||||||
|
const {gradingSystem} = useGradingSystem();
|
||||||
|
|
||||||
useEffect(() => setSelectedScore(scores.find((x) => x.module === selectedModule)!), [scores, selectedModule]);
|
useEffect(() => setSelectedScore(scores.find((x) => x.module === selectedModule)!), [scores, selectedModule]);
|
||||||
|
|
||||||
@@ -61,7 +87,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
|
|
||||||
const getTotalExercises = () => {
|
const getTotalExercises = () => {
|
||||||
const exam = exams.find((x) => x.module === selectedModule)!;
|
const exam = exams.find((x) => x.module === selectedModule)!;
|
||||||
if (exam.module === "reading" || exam.module === "listening") {
|
if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") {
|
||||||
return exam.parts.flatMap((x) => x.exercises).length;
|
return exam.parts.flatMap((x) => x.exercises).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,10 +98,10 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
|
|
||||||
const showLevel = (level: number) => {
|
const showLevel = (level: number) => {
|
||||||
if (selectedModule === "level") {
|
if (selectedModule === "level") {
|
||||||
const [levelStr, grade] = getLevelScore(level);
|
const label = getGradingLabel(level, gradingSystem?.steps || []);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-1">
|
<div className="flex flex-col items-center justify-center gap-1">
|
||||||
<span className="text-xl font-bold">{levelStr}</span>
|
<span className="text-xl font-bold">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -85,6 +111,21 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Modal title="Extra Information" isOpen={isExtraInformationOpen} onClose={() => setIsExtraInformationOpen(false)}>
|
||||||
|
<div className="flex flex-col gap-2 mt-4">
|
||||||
|
{!!information.timeSpent && (
|
||||||
|
<span>
|
||||||
|
<b>Time Spent:</b> {Math.floor(information.timeSpent / 60)} minute(s)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!!information.inactivity && (
|
||||||
|
<span>
|
||||||
|
<b>Inactivity:</b> {Math.floor(information.inactivity / 60)} minute(s)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<div className="flex h-fit min-h-full w-full flex-col items-center justify-between gap-8">
|
<div className="flex h-fit min-h-full w-full flex-col items-center justify-between gap-8">
|
||||||
<ModuleTitle
|
<ModuleTitle
|
||||||
module={selectedModule}
|
module={selectedModule}
|
||||||
@@ -93,7 +134,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
minTimer={exams.find((x) => x.module === selectedModule)!.minTimer}
|
minTimer={exams.find((x) => x.module === selectedModule)!.minTimer}
|
||||||
disableTimer
|
disableTimer
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-4 self-start">
|
<div className="flex gap-4 self-start w-full">
|
||||||
{modules.includes("reading") && (
|
{modules.includes("reading") && (
|
||||||
<div
|
<div
|
||||||
onClick={() => setSelectedModule("reading")}
|
onClick={() => setSelectedModule("reading")}
|
||||||
@@ -117,14 +158,25 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{modules.includes("writing") && (
|
{modules.includes("writing") && (
|
||||||
<div
|
<div className="flex w-full justify-between items-center">
|
||||||
onClick={() => setSelectedModule("writing")}
|
<div
|
||||||
className={clsx(
|
onClick={() => setSelectedModule("writing")}
|
||||||
"hover:bg-ielts-writing flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
|
className={clsx(
|
||||||
selectedModule === "writing" ? "bg-ielts-writing text-white" : "bg-mti-gray-smoke text-ielts-writing",
|
"hover:bg-ielts-writing flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
|
||||||
)}>
|
selectedModule === "writing" ? "bg-ielts-writing text-white" : "bg-mti-gray-smoke text-ielts-writing",
|
||||||
<BsPen className="h-6 w-6" />
|
)}>
|
||||||
<span className="font-semibold">Writing</span>
|
<BsPen className="h-6 w-6" />
|
||||||
|
<span className="font-semibold">Writing</span>
|
||||||
|
</div>
|
||||||
|
{aiUsage >= 50 && user.type !== "student" && (
|
||||||
|
<div
|
||||||
|
className={clsx("flex items-center justify-center border px-3 h-full rounded", {
|
||||||
|
"bg-orange-100 border-orange-400 text-orange-700": aiUsage < 80,
|
||||||
|
"bg-red-100 border-red-400 text-red-700": aiUsage >= 80,
|
||||||
|
})}>
|
||||||
|
<span className="text-xs">AI Usage</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{modules.includes("speaking") && (
|
{modules.includes("speaking") && (
|
||||||
@@ -160,7 +212,18 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isLoading && (
|
{assignment && !assignment.released && !isLoading && (
|
||||||
|
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-12">
|
||||||
|
{/* <span className={clsx("loading loading-infinity w-32", moduleColors[selectedModule].progress)} /> */}
|
||||||
|
<BsBan size={64} className={clsx(moduleColors[selectedModule].progress)} />
|
||||||
|
<span className={clsx("text-center text-2xl font-bold", moduleColors[selectedModule].progress)}>
|
||||||
|
This exam has not yet been released by its assigner.
|
||||||
|
<br />
|
||||||
|
You can check it later on your records page when it is released!
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && !(assignment && !assignment.released) && (
|
||||||
<div className="mb-20 mt-32 flex w-full items-center justify-between gap-9">
|
<div className="mb-20 mt-32 flex w-full items-center justify-between gap-9">
|
||||||
<span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span>
|
<span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span>
|
||||||
<div className="flex gap-9 px-16">
|
<div className="flex gap-9 px-16">
|
||||||
@@ -182,33 +245,37 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
{showLevel(bandScore)}
|
{showLevel(bandScore)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-5">
|
{!["writing", "speaking"].includes(selectedModule) ? (
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col gap-5 w-28">
|
||||||
<div className="bg-mti-red-light mt-1 h-3 w-3 rounded-full" />
|
<div className="flex gap-2">
|
||||||
<div className="flex flex-col">
|
<div className="bg-mti-red-light mt-1 h-3 w-3 rounded-full" />
|
||||||
<span className="text-mti-red-light">
|
<div className="flex flex-col">
|
||||||
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
|
<span className="text-mti-red-light">
|
||||||
</span>
|
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
|
||||||
<span className="text-lg">Completion</span>
|
</span>
|
||||||
|
<span className="text-lg">Completion</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="bg-mti-purple-light mt-1 h-3 w-3 rounded-full" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
|
||||||
|
<span className="text-lg">Correct</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="bg-mti-rose-light mt-1 h-3 w-3 rounded-full" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-mti-rose-light">
|
||||||
|
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg">Wrong</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
) : (
|
||||||
<div className="bg-mti-purple-light mt-1 h-3 w-3 rounded-full" />
|
<div className="w-28 h-full" />
|
||||||
<div className="flex flex-col">
|
)}
|
||||||
<span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
|
|
||||||
<span className="text-lg">Correct</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="bg-mti-rose-light mt-1 h-3 w-3 rounded-full" />
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-mti-rose-light">
|
|
||||||
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
<span className="text-lg">Wrong</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -220,6 +287,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
|
disabled={user.type === "admin"}
|
||||||
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||||
<BsArrowCounterclockwise className="h-7 w-7 text-white" />
|
<BsArrowCounterclockwise className="h-7 w-7 text-white" />
|
||||||
</button>
|
</button>
|
||||||
@@ -227,12 +295,32 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={onViewResults}
|
onClick={() => onViewResults()}
|
||||||
|
disabled={assignment && !assignment.released}
|
||||||
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||||
<BsEyeFill className="h-7 w-7 text-white" />
|
<BsEyeFill className="h-7 w-7 text-white" />
|
||||||
</button>
|
</button>
|
||||||
<span>Review Answers</span>
|
<span>Review All</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||||
|
<button
|
||||||
|
disabled={assignment && !assignment.released}
|
||||||
|
onClick={() => onViewResults(modules.findIndex((x) => x === selectedModule))}
|
||||||
|
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||||
|
<BsEyeFill className="h-7 w-7 text-white" />
|
||||||
|
</button>
|
||||||
|
<span>Review {capitalize(selectedModule)}</span>
|
||||||
|
</div>
|
||||||
|
{(!!information.inactivity || !!information.timeSpent) && (
|
||||||
|
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExtraInformationOpen(true)}
|
||||||
|
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||||
|
<BsClipboardFill className="h-7 w-7 text-white" />
|
||||||
|
</button>
|
||||||
|
<span>Extra Information</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link href="/" className="w-full max-w-[200px] self-end">
|
<Link href="/" className="w-full max-w-[200px] self-end">
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
import {renderExercise} from "@/components/Exercises";
|
|
||||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
|
||||||
import {renderSolution} from "@/components/Solutions";
|
|
||||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
|
||||||
import {LevelExam, UserSolution, WritingExam} from "@/interfaces/exam";
|
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import {defaultUserSolutions} from "@/utils/exams";
|
|
||||||
import {countExercises} from "@/utils/moduleUtils";
|
|
||||||
import {mdiArrowRight} from "@mdi/js";
|
|
||||||
import Icon from "@mdi/react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import {Fragment, useEffect, useState} from "react";
|
|
||||||
import {toast} from "react-toastify";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
exam: LevelExam;
|
|
||||||
showSolutions?: boolean;
|
|
||||||
onFinish: (userSolutions: UserSolution[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
|
||||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
|
||||||
const [exerciseIndex, setExerciseIndex] = useState(0);
|
|
||||||
|
|
||||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
|
||||||
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentQuestionIndex(0);
|
|
||||||
}, [questionIndex]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasExamEnded && exerciseIndex === -1) {
|
|
||||||
setExerciseIndex((prev) => prev + 1);
|
|
||||||
}
|
|
||||||
}, [hasExamEnded, exerciseIndex]);
|
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
|
||||||
if (solution) {
|
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
|
||||||
}
|
|
||||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
|
||||||
setExerciseIndex((prev) => prev + 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exerciseIndex >= exam.exercises.length) return;
|
|
||||||
|
|
||||||
setHasExamEnded(false);
|
|
||||||
|
|
||||||
if (solution) {
|
|
||||||
onFinish(
|
|
||||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "level", exam: exam.id})),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
onFinish(userSolutions.map((x) => ({...x, module: "level", exam: exam.id})));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
|
||||||
if (solution) {
|
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exerciseIndex > 0) {
|
|
||||||
setExerciseIndex((prev) => prev - 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getExercise = () => {
|
|
||||||
const exercise = exam.exercises[exerciseIndex];
|
|
||||||
return {
|
|
||||||
...exercise,
|
|
||||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col h-full w-full gap-8 items-center">
|
|
||||||
<ModuleTitle
|
|
||||||
minTimer={exam.minTimer}
|
|
||||||
exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex}
|
|
||||||
module="level"
|
|
||||||
totalExercises={countExercises(exam.exercises)}
|
|
||||||
disableTimer={showSolutions}
|
|
||||||
/>
|
|
||||||
{exerciseIndex > -1 &&
|
|
||||||
exerciseIndex < exam.exercises.length &&
|
|
||||||
!showSolutions &&
|
|
||||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
|
||||||
{exerciseIndex > -1 &&
|
|
||||||
exerciseIndex < exam.exercises.length &&
|
|
||||||
showSolutions &&
|
|
||||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user