Compare commits
171 Commits
ENCOA-83_M
...
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 |
@@ -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 [
|
||||||
|
|||||||
76
package-lock.json
generated
76
package-lock.json
generated
@@ -41,6 +41,7 @@
|
|||||||
"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",
|
||||||
@@ -64,7 +65,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",
|
||||||
@@ -4056,6 +4057,20 @@
|
|||||||
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz",
|
||||||
"integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw=="
|
"integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/babel-runtime": {
|
||||||
|
"version": "6.26.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
|
||||||
|
"integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==",
|
||||||
|
"dependencies": {
|
||||||
|
"core-js": "^2.4.0",
|
||||||
|
"regenerator-runtime": "^0.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/babel-runtime/node_modules/regenerator-runtime": {
|
||||||
|
"version": "0.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
|
||||||
|
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg=="
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -4619,6 +4634,13 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/core-js": {
|
||||||
|
"version": "2.6.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
|
||||||
|
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
|
||||||
|
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
|
||||||
|
"hasInstallScript": true
|
||||||
|
},
|
||||||
"node_modules/core-util-is": {
|
"node_modules/core-util-is": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
@@ -6224,6 +6246,17 @@
|
|||||||
"@google-cloud/storage": "^6.9.5"
|
"@google-cloud/storage": "^6.9.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/firebase-scrypt": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/firebase-scrypt/-/firebase-scrypt-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-36vJZVPFepErsNw+nBjb9cpM9wYPtcxk1bKN//vLdVkNPhaw1cogzwxtMs0s+dYg1gvBDakg2Q4ch8zAWAvnxA==",
|
||||||
|
"dependencies": {
|
||||||
|
"babel-runtime": "^6.26.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/firebase/node_modules/@firebase/util": {
|
"node_modules/firebase/node_modules/@firebase/util": {
|
||||||
"version": "1.9.3",
|
"version": "1.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.9.3.tgz",
|
||||||
@@ -9635,9 +9668,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-icons": {
|
"node_modules/react-icons": {
|
||||||
"version": "4.10.1",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.3.0.tgz",
|
||||||
"integrity": "sha512-/ngzDP/77tlCfqthiiGNZeYFACw85fUjZtLbedmJ5DTlNDIwETxhwBzdOJ21zj4iJdvc0J3y7yOsX3PpxAJzrw==",
|
"integrity": "sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg==",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "*"
|
"react": "*"
|
||||||
}
|
}
|
||||||
@@ -14697,6 +14730,22 @@
|
|||||||
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz",
|
||||||
"integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw=="
|
"integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw=="
|
||||||
},
|
},
|
||||||
|
"babel-runtime": {
|
||||||
|
"version": "6.26.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
|
||||||
|
"integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==",
|
||||||
|
"requires": {
|
||||||
|
"core-js": "^2.4.0",
|
||||||
|
"regenerator-runtime": "^0.11.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"regenerator-runtime": {
|
||||||
|
"version": "0.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
|
||||||
|
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"balanced-match": {
|
"balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -15083,6 +15132,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
|
||||||
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw=="
|
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw=="
|
||||||
},
|
},
|
||||||
|
"core-js": {
|
||||||
|
"version": "2.6.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
|
||||||
|
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ=="
|
||||||
|
},
|
||||||
"core-util-is": {
|
"core-util-is": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
@@ -16352,6 +16406,14 @@
|
|||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"firebase-scrypt": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/firebase-scrypt/-/firebase-scrypt-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-36vJZVPFepErsNw+nBjb9cpM9wYPtcxk1bKN//vLdVkNPhaw1cogzwxtMs0s+dYg1gvBDakg2Q4ch8zAWAvnxA==",
|
||||||
|
"requires": {
|
||||||
|
"babel-runtime": "^6.26.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"flat-cache": {
|
"flat-cache": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
|
||||||
@@ -18839,9 +18901,9 @@
|
|||||||
"integrity": "sha512-y2UpWs82xs+39q5Rc/wq316ca52QsC0n8m801V+yM4IC4hbfOL4yQPVSh7w+ydstdvjN9F+lvs1WrO2VYxpmdA=="
|
"integrity": "sha512-y2UpWs82xs+39q5Rc/wq316ca52QsC0n8m801V+yM4IC4hbfOL4yQPVSh7w+ydstdvjN9F+lvs1WrO2VYxpmdA=="
|
||||||
},
|
},
|
||||||
"react-icons": {
|
"react-icons": {
|
||||||
"version": "4.10.1",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.3.0.tgz",
|
||||||
"integrity": "sha512-/ngzDP/77tlCfqthiiGNZeYFACw85fUjZtLbedmJ5DTlNDIwETxhwBzdOJ21zj4iJdvc0J3y7yOsX3PpxAJzrw=="
|
"integrity": "sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg=="
|
||||||
},
|
},
|
||||||
"react-is": {
|
"react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
|
|||||||
@@ -28,7 +28,8 @@
|
|||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@use-gesture/react": "^10.3.1",
|
"@use-gesture/react": "^10.3.1",
|
||||||
"axios": "^1.3.5",
|
"axios": "^1",
|
||||||
|
"axios-cache-interceptor": "^1",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"chart.js": "^4.2.1",
|
"chart.js": "^4.2.1",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
"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",
|
||||||
@@ -66,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",
|
||||||
@@ -79,7 +81,7 @@
|
|||||||
"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-merge": "^2.5.2",
|
||||||
"tailwind-scrollbar-hide": "^1.1.7",
|
"tailwind-scrollbar-hide": "^1.1.7",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
@@ -90,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",
|
||||||
|
|||||||
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 |
@@ -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/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;
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
|
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Fragment, useEffect, useState } from "react";
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import reactStringReplace from "react-string-replace";
|
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";
|
import { v4 } from "uuid";
|
||||||
|
import MCDropdown from "./MCDropdown";
|
||||||
|
|
||||||
const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
||||||
id,
|
id,
|
||||||
@@ -20,33 +20,40 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
|||||||
onNext,
|
onNext,
|
||||||
onBack,
|
onBack,
|
||||||
}) => {
|
}) => {
|
||||||
const { shuffleMaps, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state);
|
const { shuffles, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state);
|
||||||
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 = useExamStore((state) => state.hasExamEnded);
|
||||||
|
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
|
||||||
const [currentMCSelection, setCurrentMCSelection] = useState<{ id: string, selection: FillBlanksMCOption }>();
|
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
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 excludeWordMCType = (x: any) => {
|
const excludeWordMCType = (x: any) => {
|
||||||
return typeof x === "string" ? x : x as { letter: string; word: string };
|
return typeof x === "string" ? x : (x as { letter: string; word: string });
|
||||||
}
|
};
|
||||||
|
|
||||||
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]);
|
||||||
|
|
||||||
|
|
||||||
let correctWords: any;
|
let correctWords: any;
|
||||||
if (exam && exam.module === "level" && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") {
|
if (exam && (exam.module === "level" || exam.module === "reading") && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") {
|
||||||
correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words;
|
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 calculateScore = () => {
|
||||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||||
const correct = answers!.filter((x) => {
|
const correct = answers!.filter((x) => {
|
||||||
@@ -55,8 +62,8 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
|||||||
const option = correctWords!.find((w: any) => {
|
const option = correctWords!.find((w: any) => {
|
||||||
if (typeof w === "string") {
|
if (typeof w === "string") {
|
||||||
return w.toLowerCase() === x.solution.toLowerCase();
|
return w.toLowerCase() === x.solution.toLowerCase();
|
||||||
} else if ('letter' in w) {
|
} else if ("letter" in w) {
|
||||||
return w.word.toLowerCase() === x.solution.toLowerCase();
|
return w.letter.toLowerCase() === x.solution.toLowerCase();
|
||||||
} else {
|
} else {
|
||||||
return w.id.toString() === x.id.toString();
|
return w.id.toString() === x.id.toString();
|
||||||
}
|
}
|
||||||
@@ -65,9 +72,9 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
|||||||
|
|
||||||
if (typeof option === "string") {
|
if (typeof option === "string") {
|
||||||
return solution.toLowerCase() === option.toLowerCase();
|
return solution.toLowerCase() === option.toLowerCase();
|
||||||
} else if ('letter' in option) {
|
} else if ("letter" in option) {
|
||||||
return solution.toLowerCase() === option.word.toLowerCase();
|
return solution.toLowerCase() === option.word.toLowerCase();
|
||||||
} else if ('options' in option) {
|
} else if ("options" in option) {
|
||||||
return option.options[solution as keyof typeof option.options] == x.solution;
|
return option.options[solution as keyof typeof option.options] == x.solution;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -75,123 +82,111 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
|||||||
const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||||
return { total, correct, missing };
|
return { total, correct, missing };
|
||||||
};
|
};
|
||||||
const renderLines = (line: string) => {
|
|
||||||
|
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const renderLines = useCallback(
|
||||||
|
(line: string) => {
|
||||||
return (
|
return (
|
||||||
<div className="text-base leading-5">
|
<div className="text-xl leading-5" key={v4()} ref={dropdownRef}>
|
||||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||||
const id = match.replaceAll(/[\{\}]/g, "");
|
const id = match.replaceAll(/[\{\}]/g, "");
|
||||||
const userSolution = answers.find((x) => x.id === id);
|
const userSolution = answers.find((x) => x.id === id);
|
||||||
const styles = clsx(
|
const styles = clsx(
|
||||||
"rounded-full hover:text-white focus:ring-0 focus:outline-none focus:!text-white focus:bg-mti-purple transition duration-300 ease-in-out my-1 px-5 py-2 text-center",
|
"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-light bg-mti-purple-ultralight",
|
||||||
userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight",
|
userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight",
|
||||||
)
|
);
|
||||||
return (
|
|
||||||
variant === "mc" ? (
|
const currentSelection = words.find((x) => {
|
||||||
<>
|
if (typeof x !== "string" && "id" in x) {
|
||||||
{/*<span className="mr-2">{`(${id})`}</span>*/}
|
|
||||||
<button
|
|
||||||
className={styles}
|
|
||||||
onClick={() => {
|
|
||||||
setCurrentMCSelection(
|
|
||||||
{
|
|
||||||
id: id,
|
|
||||||
selection: words.find((x) => {
|
|
||||||
if (typeof x !== "string" && 'id' in x) {
|
|
||||||
return (x as FillBlanksMCOption).id.toString() == id.toString();
|
return (x as FillBlanksMCOption).id.toString() == id.toString();
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}) as FillBlanksMCOption
|
}) as FillBlanksMCOption;
|
||||||
}
|
|
||||||
);
|
return variant === "mc" ? (
|
||||||
}}
|
<MCDropdown
|
||||||
>
|
id={id}
|
||||||
{userSolution?.solution === undefined ? <span className="text-transparent select-none">placeholder</span> : <span> {userSolution.solution} </span>}
|
options={currentSelection.options}
|
||||||
</button>
|
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
|
<input
|
||||||
className={styles}
|
className={styles}
|
||||||
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])}
|
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])}
|
||||||
value={userSolution?.solution} />
|
value={userSolution?.solution}
|
||||||
)
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
|
}
|
||||||
</div >
|
</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 }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSelection = (id: string, value: string) => {
|
useEffect(() => {
|
||||||
setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id: id, solution: value }]);
|
if (variant === "mc") {
|
||||||
}
|
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
||||||
|
|
||||||
const getShuffles = () => {
|
|
||||||
let shuffle = {};
|
|
||||||
if (shuffleMaps.length !== 0) {
|
|
||||||
shuffle = {
|
|
||||||
shuffleMaps: shuffleMaps.filter((map) =>
|
|
||||||
answers.some(answer => answer.id === map.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return shuffle;
|
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [answers]);
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
{false && <span className="text-sm w-full leading-6">
|
{variant !== "mc" && (
|
||||||
|
<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}>
|
||||||
{line}
|
{line}
|
||||||
<br />
|
<br />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</span>}
|
|
||||||
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
|
||||||
{text.split("\\n").map((line, index) => (
|
|
||||||
<p key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}>
|
|
||||||
{renderLines(line)}
|
|
||||||
<br />
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</span>
|
</span>
|
||||||
{variant === "mc" && typeCheckWordsMC(words) ? (
|
|
||||||
<>
|
|
||||||
{currentMCSelection && (
|
|
||||||
<div className="bg-mti-gray-smoke rounded-xl flex flex-col gap-4 px-16 py-8">
|
|
||||||
<span className="font-medium text-lg text-mti-purple-dark mb-4 px-2">{`${currentMCSelection.id} - Select the appropriate word.`}</span>
|
|
||||||
<div className="flex gap-4 flex-wrap justify-between">
|
|
||||||
{currentMCSelection.selection?.options && Object.entries(currentMCSelection.selection.options).sort((a, b) => a[0].localeCompare(b[0])).map(([key, value]) => {
|
|
||||||
return <div
|
|
||||||
key={v4()}
|
|
||||||
onClick={() => onSelection(currentMCSelection.id, value)}
|
|
||||||
className={clsx(
|
|
||||||
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base",
|
|
||||||
!!answers.find((x) => x.solution.toLocaleLowerCase() === value.toLocaleLowerCase() && x.id === currentMCSelection.id) &&
|
|
||||||
"border-mti-purple-light",
|
|
||||||
)}>
|
|
||||||
<span className="font-semibold">{key}.</span>
|
|
||||||
<span>{value}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
/*<button
|
|
||||||
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.toLocaleLowerCase() === value.toLocaleLowerCase() && x.id === currentMCSelection.id) &&
|
|
||||||
"bg-mti-purple-dark text-white",
|
|
||||||
)}
|
)}
|
||||||
key={v4()}
|
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">{memoizedLines}</span>
|
||||||
onClick={() => onSelection(currentMCSelection.id, value)}
|
{variant !== "mc" && (
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</button>;*/
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-4">
|
<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>
|
<span className="font-medium text-mti-purple-dark">Options</span>
|
||||||
<div className="flex gap-4 flex-wrap">
|
<div className="flex gap-4 flex-wrap">
|
||||||
@@ -203,14 +198,16 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
|||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300",
|
"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()) &&
|
!!answers.find(
|
||||||
"bg-mti-purple-dark text-white",
|
(x) =>
|
||||||
|
x.solution.toLowerCase() ===
|
||||||
|
(typeof v === "string" ? v : "letter" in v ? v.letter : "").toLowerCase(),
|
||||||
|
) && "bg-mti-purple-dark text-white",
|
||||||
)}
|
)}
|
||||||
key={v4()}
|
key={v4()}>
|
||||||
>
|
|
||||||
{text}
|
{text}
|
||||||
</span>
|
</span>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -220,24 +217,23 @@ const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() })}
|
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps })}
|
||||||
className="max-w-[200px] w-full"
|
className="max-w-[200px] w-full"
|
||||||
disabled={
|
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
||||||
exam && typeof partIndex !== "undefined" && exam.module === "level" &&
|
Previous Page
|
||||||
typeof exam.parts[0].intro === "string" && questionIndex === 0}
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
onClick={() => onNext({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() })}
|
onClick={() => {
|
||||||
|
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
||||||
|
}}
|
||||||
className="max-w-[200px] self-end w-full">
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next Page
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default FillBlanks;
|
export default FillBlanks;
|
||||||
|
|||||||
@@ -152,6 +152,16 @@ export default function InteractiveSpeaking({
|
|||||||
};
|
};
|
||||||
|
|
||||||
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" 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 className="flex flex-col h-full w-full gap-9">
|
<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 w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -293,5 +303,6 @@ export default function InteractiveSpeaking({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
|
|
||||||
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) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
if (event.over && event.over.id.toString().startsWith("droppable")) {
|
if (event.over && event.over.id.toString().startsWith("droppable")) {
|
||||||
const optionID = event.active.id.toString().replace("draggable_option_", "");
|
const optionID = event.active.id.toString().replace("draggable_option_", "");
|
||||||
@@ -93,7 +100,24 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
}, [hasExamEnded]);
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
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: 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">
|
<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) => (
|
||||||
@@ -143,6 +167,6 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* 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";
|
||||||
@@ -18,13 +18,12 @@ function Question({
|
|||||||
}: MultipleChoiceQuestion & {
|
}: MultipleChoiceQuestion & {
|
||||||
userSolution: string | undefined;
|
userSolution: string | undefined;
|
||||||
onSelectOption?: (option: string) => void;
|
onSelectOption?: (option: string) => void;
|
||||||
showSolution?: boolean,
|
showSolution?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
|
||||||
const renderPrompt = (prompt: string) => {
|
const renderPrompt = (prompt: string) => {
|
||||||
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
|
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
|
||||||
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
|
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
|
||||||
return word.length > 0 ? <u>{word}</u> : null;
|
return word.length > 0 ? <u key={v4()}>{word}</u> : null;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,7 +48,9 @@ function Question({
|
|||||||
"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",
|
"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>
|
||||||
))}
|
))}
|
||||||
@@ -60,7 +61,7 @@ function Question({
|
|||||||
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-base select-none",
|
"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>
|
||||||
@@ -74,36 +75,37 @@ function Question({
|
|||||||
export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
||||||
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||||
|
|
||||||
const {
|
const {questionIndex, exerciseIndex, exam, shuffles, hasExamEnded, partIndex, setQuestionIndex, setCurrentSolution} = useExamStore(
|
||||||
questionIndex,
|
(state) => state,
|
||||||
exam,
|
);
|
||||||
shuffleMaps,
|
|
||||||
hasExamEnded,
|
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
|
||||||
userSolutions: storeUserSolutions,
|
|
||||||
setQuestionIndex,
|
|
||||||
setUserSolutions
|
|
||||||
} = useExamStore((state) => state);
|
|
||||||
|
|
||||||
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]);
|
||||||
|
|
||||||
const onSelectOption = (option: string) => {
|
const onSelectOption = (option: string, question: MultipleChoiceQuestion) => {
|
||||||
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((x) => {
|
const correct = answers.filter((x) => {
|
||||||
@@ -112,75 +114,108 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti
|
|||||||
});
|
});
|
||||||
|
|
||||||
let isSolutionCorrect;
|
let isSolutionCorrect;
|
||||||
if (shuffleMaps.length == 0) {
|
if (!shuffleMaps) {
|
||||||
isSolutionCorrect = matchingQuestion?.solution === x.option;
|
isSolutionCorrect = matchingQuestion?.solution === x.option;
|
||||||
} else {
|
} else {
|
||||||
const shuffleMap = shuffleMaps.find((map) => map.id == x.question)
|
const shuffleMap = shuffleMaps.find((map) => map.questionID == x.question);
|
||||||
isSolutionCorrect = shuffleMap?.map[x.option] == matchingQuestion?.solution;
|
if (shuffleMap) {
|
||||||
|
isSolutionCorrect = getShuffledSolution(x.option, shuffleMap) == matchingQuestion?.solution;
|
||||||
|
} else {
|
||||||
|
isSolutionCorrect = matchingQuestion?.solution === x.option;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return isSolutionCorrect || false;
|
return isSolutionCorrect || false;
|
||||||
}).length;
|
}).length;
|
||||||
const missing = total - correct;
|
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 getShuffles = () => {
|
|
||||||
let shuffle = {};
|
|
||||||
if (shuffleMaps.length !== 0) {
|
|
||||||
shuffle = {
|
|
||||||
shuffleMaps: shuffleMaps.filter((map) =>
|
|
||||||
answers.some(answer => answer.question === map.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return shuffle;
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
if (questionIndex === questions.length - 1) {
|
if (questionIndex + 1 >= questions.length - 1) {
|
||||||
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() });
|
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, ...getShuffles() });
|
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 w-full mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8">
|
<div className="flex justify-between w-full gap-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 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>*/}
|
{/*<span className="text-xl font-semibold mb-2">{"Select the appropriate option."}</span>*/}
|
||||||
{questionIndex < questions.length && (
|
{questionIndex < questions.length && (
|
||||||
<Question
|
<Question
|
||||||
{...questions[questionIndex]}
|
{...questions[questionIndex]}
|
||||||
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
|
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
|
||||||
onSelectOption={onSelectOption}
|
onSelectOption={(option) => onSelectOption(option, questions[questionIndex])}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<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
|
||||||
disabled={
|
color="purple"
|
||||||
exam && exam.module === "level" && typeof exam.parts[0].intro === "string" && questionIndex === 0}
|
variant="outline"
|
||||||
>
|
onClick={back}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,16 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
};
|
};
|
||||||
|
|
||||||
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" 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 className="flex flex-col h-full w-full gap-9">
|
<div className="flex flex-col h-full w-full gap-9">
|
||||||
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
|
<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 items-center justify-center gap-4 w-full h-full">
|
||||||
@@ -134,7 +144,9 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
<div className="flex flex-col gap-0">
|
<div className="flex flex-col gap-0">
|
||||||
<span className="font-semibold">{title}</span>
|
<span className="font-semibold">{title}</span>
|
||||||
{prompts.length > 0 && (
|
{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>
|
<span className="font-semibold">
|
||||||
|
You should talk for at least 1 minute and 30 seconds for your answer to be valid.
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!video_url && (
|
{!video_url && (
|
||||||
@@ -300,5 +312,6 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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,7 +45,24 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
};
|
};
|
||||||
|
|
||||||
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: 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">
|
<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) => (
|
||||||
@@ -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,7 +92,24 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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})}
|
||||||
|
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">
|
<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) => (
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {FillBlanksExercise} from "@/interfaces/exam";
|
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
exercise: FillBlanksExercise;
|
exercise: FillBlanksExercise;
|
||||||
@@ -9,10 +10,15 @@ interface Props {
|
|||||||
|
|
||||||
const FillBlanksEdit = (props: Props) => {
|
const FillBlanksEdit = (props: Props) => {
|
||||||
const { exercise, updateExercise } = 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type={exercise?.variant && exercise.variant === "mc" ? "textarea" : "text"}
|
||||||
label="Prompt"
|
label="Prompt"
|
||||||
name="prompt"
|
name="prompt"
|
||||||
required
|
required
|
||||||
@@ -24,18 +30,18 @@ const FillBlanksEdit = (props: Props) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type={exercise?.variant && exercise.variant === "mc" ? "textarea" : "text"}
|
||||||
label="Text"
|
label="Text"
|
||||||
name="text"
|
name="text"
|
||||||
required
|
required
|
||||||
value={exercise.text}
|
value={exercise.text}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
updateExercise({
|
updateExercise({
|
||||||
text: value,
|
text: exercise?.variant && exercise.variant === "mc" ? value : value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<h1>Solutions</h1>
|
<h1 className="mt-4">Solutions</h1>
|
||||||
<div className="w-full flex flex-wrap -mx-2">
|
<div className="w-full flex flex-wrap -mx-2">
|
||||||
{exercise.solutions.map((solution, index) => (
|
{exercise.solutions.map((solution, index) => (
|
||||||
<div key={solution.id} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
<div key={solution.id} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
||||||
@@ -54,9 +60,49 @@ const FillBlanksEdit = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<h1>Words</h1>
|
<h1 className="mt-4">Words</h1>
|
||||||
<div className="w-full flex flex-wrap -mx-2">
|
<div className={clsx(exercise?.variant && exercise.variant === "mc" ? "w-full flex flex-row" : "w-full flex flex-wrap -mx-2")}>
|
||||||
{exercise.words.map((word, index) => (
|
{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">
|
<div key={index} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -73,7 +119,9 @@ const FillBlanksEdit = (props: Props) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,7 +63,8 @@ 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",
|
||||||
|
padding,
|
||||||
colorClassNames[color][variant],
|
colorClassNames[color][variant],
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
onChange(!isChecked);
|
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 ",
|
||||||
)}>
|
)}>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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,28 +1,53 @@
|
|||||||
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 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;
|
partLabel?: string;
|
||||||
showTimer?: boolean;
|
showTimer?: boolean;
|
||||||
|
showSolutions?: boolean;
|
||||||
|
currentExercise?: Exercise;
|
||||||
|
runOnClick?: ((questionIndex: number) => void) | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ModuleTitle({
|
export default function ModuleTitle({
|
||||||
minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false, partLabel, showTimer = true
|
minTimer,
|
||||||
|
module,
|
||||||
|
label,
|
||||||
|
examLabel,
|
||||||
|
exerciseIndex,
|
||||||
|
totalExercises,
|
||||||
|
disableTimer = false,
|
||||||
|
partLabel,
|
||||||
|
showTimer = true,
|
||||||
|
showSolutions = false,
|
||||||
|
runOnClick = undefined
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const {
|
||||||
|
userSolutions,
|
||||||
|
partIndex,
|
||||||
|
exam
|
||||||
|
} = useExamStore((state) => state);
|
||||||
|
const examExerciseIndex = useExamStore((state) => state.exerciseIndex)
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const moduleIcon: { [key in Module]: ReactNode } = {
|
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" />,
|
||||||
@@ -32,24 +57,97 @@ export default function ModuleTitle({
|
|||||||
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 (
|
||||||
<>
|
<>
|
||||||
{showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />}
|
{showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />}
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{partLabel && (
|
{partLabel && (
|
||||||
<div className="text-3xl space-y-4">
|
<div className="text-3xl space-y-4">
|
||||||
{partLabel.split("\n\n").map((line, index) => {
|
{partLabel.split("\n\n").map((partInstructions, index) => {
|
||||||
if (index == 0)
|
if (index === 0)
|
||||||
return (
|
return (
|
||||||
<p key={index} className="font-bold">
|
<p key={index} className="font-bold">
|
||||||
{line}
|
{partInstructions}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
else
|
else
|
||||||
return (
|
return (
|
||||||
<p key={index} className="text-2xl font-semibold">
|
<div key={index} className="text-2xl font-semibold flex flex-col gap-2">
|
||||||
{line}
|
{partInstructions.split("\\n").map((line, lineIndex) => (
|
||||||
</p>
|
<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>
|
</div>
|
||||||
@@ -59,7 +157,10 @@ export default function ModuleTitle({
|
|||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<div className="w-full flex justify-between">
|
<div className="w-full flex justify-between">
|
||||||
<span className="text-base font-semibold">
|
<span className="text-base font-semibold">
|
||||||
{moduleLabels[module]} exam {label && `- ${label}`}
|
{module === "level"
|
||||||
|
? (examLabel ? examLabel : "Placement Test")
|
||||||
|
: `${moduleLabels[module]} exam${label ? ` - ${label}` : ''}`
|
||||||
|
}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-semibold self-end">
|
<span className="text-sm font-semibold self-end">
|
||||||
Question {exerciseIndex}/{totalExercises}
|
Question {exerciseIndex}/{totalExercises}
|
||||||
@@ -67,6 +168,22 @@ export default function ModuleTitle({
|
|||||||
</div>
|
</div>
|
||||||
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
|
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
|
||||||
</div>
|
</div>
|
||||||
|
{isMultipleChoiceLevelExercise() && (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setIsOpen(true)} padding="p-2" className="rounded-lg">
|
||||||
|
<BsFillGrid3X3GapFill size={24} />
|
||||||
|
</Button>
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white shadow-xl transition-all"
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
{renderMCQuestionGrid()}
|
||||||
|
</>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</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,7 +40,8 @@ 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">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
<span className="flex gap-1">
|
<span className="flex gap-1">
|
||||||
<b>ID:</b>
|
<b>ID:</b>
|
||||||
{session.sessionId}
|
{session.sessionId}
|
||||||
@@ -49,6 +50,14 @@ export default function SessionCard({
|
|||||||
<b>Date:</b>
|
<b>Date:</b>
|
||||||
{moment(session.date).format("DD/MM/YYYY - HH:mm")}
|
{moment(session.date).format("DD/MM/YYYY - HH:mm")}
|
||||||
</span>
|
</span>
|
||||||
|
{session.assignment && (
|
||||||
|
<span className="flex flex-col gap-0">
|
||||||
|
<b>Assignment:</b>
|
||||||
|
{session.assignment.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
<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-full grid-cols-4 place-items-center justify-center gap-2">
|
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-center justify-center gap-2">
|
||||||
{session.selectedModules.sort(sortByModuleName).map((module) => (
|
{session.selectedModules.sort(sortByModuleName).map((module) => (
|
||||||
@@ -97,5 +106,6 @@ export default function SessionCard({
|
|||||||
</button>
|
</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;
|
||||||
@@ -51,14 +51,14 @@ const Timer: React.FC<Props> = ({minTimer, disableTimer, standalone = false}) =>
|
|||||||
<motion.div
|
<motion.div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"absolute right-6 bg-mti-gray-seasalt px-4 py-3 flex items-center gap-2 rounded-full text-mti-gray-davy",
|
"absolute right-6 bg-mti-gray-seasalt px-4 py-3 flex items-center gap-2 rounded-full text-mti-gray-davy",
|
||||||
standalone ? "top-6" : "top-4",
|
standalone ? "top-10" : "top-4",
|
||||||
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt",
|
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt",
|
||||||
)}
|
)}
|
||||||
initial={{scale: warningMode && !disableTimer ? 0.8 : 1}}
|
initial={{scale: warningMode && !disableTimer ? 0.8 : 1}}
|
||||||
animate={{scale: warningMode && !disableTimer ? 1.1 : 1}}
|
animate={{scale: warningMode && !disableTimer ? 1.1 : 1}}
|
||||||
transition={{repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut"}}>
|
transition={{repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut"}}>
|
||||||
<BsStopwatch className="w-6 h-6" />
|
<BsStopwatch className="w-6 h-6" />
|
||||||
<span className="text-base font-semibold w-12">
|
<span className="text-lg font-bold w-12">
|
||||||
{timer > 0 && (
|
{timer > 0 && (
|
||||||
<>
|
<>
|
||||||
{Math.floor(timer / 60)
|
{Math.floor(timer / 60)
|
||||||
@@ -75,6 +75,6 @@ const Timer: React.FC<Props> = ({minTimer, disableTimer, standalone = false}) =>
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Timer;
|
export default Timer;
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ interface Props {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
title?: string;
|
title?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
titleClassName?: string;
|
||||||
children?: ReactElement;
|
children?: ReactElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Modal({isOpen, title, className, 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}>
|
||||||
@@ -41,7 +42,7 @@ export default function Modal({isOpen, title, className, onClose, children}: Pro
|
|||||||
className,
|
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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
|
import {Step} from "@/interfaces";
|
||||||
|
import {getGradingLabel, getLevelLabel} from "@/utils/score";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||||
|
|
||||||
const ModuleBadge: React.FC<{ module: string; level?: number }> = ({ module, level }) => (
|
const ModuleBadge: React.FC<{module: string; level?: number; gradingSystem?: Step[]; className?: string}> = ({
|
||||||
|
module,
|
||||||
|
level,
|
||||||
|
gradingSystem,
|
||||||
|
className,
|
||||||
|
}) => (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
|
"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 === "reading" && "bg-ielts-reading",
|
||||||
module === "listening" && "bg-ielts-listening",
|
module === "listening" && "bg-ielts-listening",
|
||||||
module === "writing" && "bg-ielts-writing",
|
module === "writing" && "bg-ielts-writing",
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
module === "level" && "bg-ielts-level",
|
module === "level" && "bg-ielts-level",
|
||||||
|
className,
|
||||||
)}>
|
)}>
|
||||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
{module === "reading" && <BsBook className="w-4 h-4" />}
|
||||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
||||||
@@ -17,7 +25,9 @@ const ModuleBadge: React.FC<{ module: string; level?: number }> = ({ module, lev
|
|||||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
||||||
{module === "level" && <BsClipboard 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*/}
|
{/* do not switch to level && it will convert the 0.0 to 0*/}
|
||||||
{level !== undefined && (<span className="text-sm">{level.toFixed(1)}</span>)}
|
{level !== undefined && (
|
||||||
|
<span className="text-sm">{module === "level" && gradingSystem ? getGradingLabel(level, gradingSystem) : level.toFixed(1)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -19,17 +19,7 @@ import TicketSubmission from "./High/TicketSubmission";
|
|||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import Badge from "./Low/Badge";
|
import Badge from "./Low/Badge";
|
||||||
|
|
||||||
import {
|
import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
||||||
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;
|
||||||
@@ -39,13 +29,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
export default function Navbar({
|
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||||
user,
|
|
||||||
path,
|
|
||||||
navDisabled = false,
|
|
||||||
focusMode = false,
|
|
||||||
onFocusLayerMouseEnter,
|
|
||||||
}: Props) {
|
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
||||||
const [isTicketOpen, setIsTicketOpen] = useState(false);
|
const [isTicketOpen, setIsTicketOpen] = useState(false);
|
||||||
@@ -58,12 +42,9 @@ export default function Navbar({
|
|||||||
const momentDate = moment(date);
|
const momentDate = moment(date);
|
||||||
const today = moment(new Date());
|
const today = moment(new Date());
|
||||||
|
|
||||||
if (today.add(1, "days").isAfter(momentDate))
|
if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light";
|
||||||
return "!bg-mti-red-ultralight border-mti-red-light";
|
if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light";
|
||||||
if (today.add(3, "days").isAfter(momentDate))
|
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light";
|
||||||
return "!bg-mti-rose-ultralight border-mti-rose-light";
|
|
||||||
if (today.add(7, "days").isAfter(momentDate))
|
|
||||||
return "!bg-mti-orange-ultralight border-mti-orange-light";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const showExpirationDate = () => {
|
const showExpirationDate = () => {
|
||||||
@@ -76,11 +57,8 @@ export default function Navbar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user.type !== "student" && user.type !== "teacher")
|
if (user.type !== "student" && user.type !== "teacher") return setDisablePaymentPage(false);
|
||||||
return setDisablePaymentPage(false);
|
isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result));
|
||||||
isUserFromCorporate(user.id).then((result) =>
|
|
||||||
setDisablePaymentPage(result)
|
|
||||||
);
|
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const badges = [
|
const badges = [
|
||||||
@@ -114,32 +92,15 @@ export default function Navbar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal
|
<Modal isOpen={isTicketOpen} onClose={() => setIsTicketOpen(false)} title="Submit a ticket">
|
||||||
isOpen={isTicketOpen}
|
<TicketSubmission user={user} page={router.asPath} onClose={() => setIsTicketOpen(false)} />
|
||||||
onClose={() => setIsTicketOpen(false)}
|
|
||||||
title="Submit a ticket"
|
|
||||||
>
|
|
||||||
<TicketSubmission
|
|
||||||
user={user}
|
|
||||||
page={router.asPath}
|
|
||||||
onClose={() => setIsTicketOpen(false)}
|
|
||||||
/>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<MobileMenu
|
<MobileMenu disableNavigation={disableNavigation} path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />
|
||||||
disableNavigation={disableNavigation}
|
|
||||||
path={path}
|
|
||||||
isOpen={isMenuOpen}
|
|
||||||
onClose={() => setIsMenuOpen(false)}
|
|
||||||
user={user}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
|
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
|
||||||
<Link
|
<Link href={disableNavigation ? "" : "/"} className=" flex items-center gap-8 md:px-8">
|
||||||
href={disableNavigation ? "" : "/"}
|
|
||||||
className=" flex items-center gap-8 md:px-8"
|
|
||||||
>
|
|
||||||
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
|
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
|
||||||
<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>
|
||||||
@@ -148,8 +109,9 @@ export default function Navbar({
|
|||||||
badges.map((badge) => (
|
badges.map((badge) => (
|
||||||
<div
|
<div
|
||||||
key={badge.module}
|
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`}
|
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()}
|
{badge.icon()}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -157,21 +119,16 @@ export default function Navbar({
|
|||||||
<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 z-20"
|
"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)}>
|
||||||
>
|
|
||||||
<BsQuestionCircleFill />
|
<BsQuestionCircleFill />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showExpirationDate() && (
|
{showExpirationDate() && (
|
||||||
<Link
|
<Link
|
||||||
href={
|
href={!!user.subscriptionExpirationDate && !disablePaymentPage ? "/payment" : ""}
|
||||||
!!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",
|
||||||
@@ -179,40 +136,29 @@ export default function Navbar({
|
|||||||
!user.subscriptionExpirationDate
|
!user.subscriptionExpirationDate
|
||||||
? "bg-mti-green-ultralight border-mti-green-light"
|
? "bg-mti-green-ultralight border-mti-green-light"
|
||||||
: expirationDateColor(user.subscriptionExpirationDate),
|
: expirationDateColor(user.subscriptionExpirationDate),
|
||||||
"border-mti-gray-platinum bg-white"
|
"border-mti-gray-platinum bg-white",
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
{!user.subscriptionExpirationDate && "Unlimited"}
|
{!user.subscriptionExpirationDate && "Unlimited"}
|
||||||
{user.subscriptionExpirationDate &&
|
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
||||||
moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<Link
|
<Link href={disableNavigation ? "" : "/profile"} className="-md:hidden flex items-center justify-end gap-6">
|
||||||
href={disableNavigation ? "" : "/profile"}
|
<img src={user.profilePicture} alt={user.name} className="h-10 w-10 rounded-full object-cover" />
|
||||||
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"
|
|
||||||
/>
|
|
||||||
<span className="-md:hidden text-right">
|
<span className="-md:hidden text-right">
|
||||||
{user.type === "corporate"
|
{(user.type === "corporate" || user.type === "mastercorporate") && !!user.corporateInformation?.companyInformation?.name
|
||||||
? `${user.corporateInformation?.companyInformation.name} |`
|
? `${user.corporateInformation?.companyInformation.name} |`
|
||||||
: ""}{" "}
|
: ""}{" "}
|
||||||
{user.name} | {USER_TYPE_LABELS[user.type]}
|
{user.name} | {USER_TYPE_LABELS[user.type]}
|
||||||
|
{user.type === "corporate" &&
|
||||||
|
!!user.demographicInformation?.position &&
|
||||||
|
` | ${user.demographicInformation?.position || "N/A"}`}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div
|
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
|
||||||
className="cursor-pointer md:hidden"
|
|
||||||
onClick={() => setIsMenuOpen(true)}
|
|
||||||
>
|
|
||||||
<BsList className="text-mti-purple-light h-8 w-8" />
|
<BsList className="text-mti-purple-light h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{focusMode && (
|
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||||
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
|
|
||||||
)}
|
|
||||||
</header>
|
</header>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,28 @@
|
|||||||
import { Dialog, Transition } from "@headlessui/react";
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
import { Fragment } from "react";
|
import { Fragment, useEffect, useState } from "react";
|
||||||
import Button from "./Low/Button";
|
import Button from "./Low/Button";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
blankQuestions?: boolean;
|
type?: "module" | "blankQuestions" | "submit";
|
||||||
finishingWhat? : string;
|
unanswered?: boolean;
|
||||||
onClose: (next?: boolean) => void;
|
onClose: (next?: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function QuestionsModal({ isOpen, onClose, blankQuestions = true, finishingWhat = "module" }: Props) {
|
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 (
|
return (
|
||||||
<Transition show={isOpen} as={Fragment}>
|
<Transition show={isOpen} as={Fragment}>
|
||||||
<Dialog onClose={() => onClose(false)} className="relative z-50">
|
<Dialog onClose={() => onClose(false)} className="relative z-50">
|
||||||
@@ -34,43 +47,71 @@ export default function QuestionsModal({ isOpen, onClose, blankQuestions = true,
|
|||||||
leaveTo="opacity-0 scale-95">
|
leaveTo="opacity-0 scale-95">
|
||||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
<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.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
|
||||||
{blankQuestions ? (
|
{type === "module" && (
|
||||||
<>
|
<>
|
||||||
<Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title>
|
<Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title>
|
||||||
<span>
|
<span>
|
||||||
Please note that you are finishing the current {finishingWhat} and once you proceed to the next {finishingWhat}, you will no longer be
|
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 />
|
able to change the answers of the current one, including your unanswered questions. <br />
|
||||||
<br />
|
<br />
|
||||||
Are you sure you want to continue without completing those questions?
|
Are you sure you want to continue without completing those questions?
|
||||||
</span>
|
</span>
|
||||||
<div className="w-full flex justify-between mt-8">
|
<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">
|
<Button color="purple" onClick={() => blockMultipleClicksClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
|
||||||
Go Back
|
Go Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full">
|
<Button color="purple" onClick={() => blockMultipleClicksClose(true)} className="max-w-[200px] self-end w-full">
|
||||||
Continue
|
Continue
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
): (
|
)}
|
||||||
|
{type === "blankQuestions" && (
|
||||||
<>
|
<>
|
||||||
<Dialog.Title className="font-bold text-xl">Confirm Submission</Dialog.Title>
|
<Dialog.Title className="font-bold text-2xl">Questions Unanswered</Dialog.Title>
|
||||||
<span>
|
<div className="flex flex-col text-xl gap-2">
|
||||||
Please note that you are finishing the current {finishingWhat} and once you proceed to the next {finishingWhat}, you will no longer be
|
<p>You have left some questions unanswered in the current part.</p>
|
||||||
able to review the answers of the current one. <br />
|
<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>
|
||||||
<br />
|
<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>
|
||||||
Are you sure you want to continue?
|
</div>
|
||||||
</span>
|
<div className="w-full flex justify-between mt-4">
|
||||||
<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">
|
||||||
<Button color="purple" onClick={() => onClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
|
|
||||||
Go Back
|
Go Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full">
|
<Button color="purple" onClick={() => blockMultipleClicksClose(true)} className="max-w-[200px] self-end w-full">
|
||||||
Continue
|
Continue
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</Dialog.Panel>
|
||||||
</div>
|
</div>
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
)}
|
)}
|
||||||
</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,4 +1,4 @@
|
|||||||
import { FillBlanksExercise, FillBlanksMCOption } 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 ".";
|
||||||
@@ -6,36 +6,25 @@ import { Fragment } from "react";
|
|||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
|
|
||||||
export default function FillBlanksSolutions({
|
export default function FillBlanksSolutions({id, type, prompt, solutions, words, text, onNext, onBack}: FillBlanksExercise & CommonProps) {
|
||||||
id,
|
|
||||||
type,
|
|
||||||
prompt,
|
|
||||||
solutions,
|
|
||||||
words,
|
|
||||||
text,
|
|
||||||
onNext,
|
|
||||||
onBack,
|
|
||||||
}: FillBlanksExercise & CommonProps) {
|
|
||||||
|
|
||||||
// next and back was all messed up and still don't know why, anyways
|
|
||||||
const storeUserSolutions = useExamStore((state) => state.userSolutions);
|
const storeUserSolutions = useExamStore((state) => state.userSolutions);
|
||||||
|
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
|
||||||
|
|
||||||
const correctUserSolutions = storeUserSolutions.find(
|
const correctUserSolutions = storeUserSolutions.find((solution) => solution.exercise === id)?.solutions;
|
||||||
(solution) => solution.exercise === id
|
|
||||||
)?.solutions;
|
const shuffles = useExamStore((state) => state.shuffles);
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||||
const correct = correctUserSolutions!.filter((x) => {
|
const correct = correctUserSolutions!.filter((x) => {
|
||||||
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
|
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
|
||||||
console.log(solution);
|
|
||||||
if (!solution) return false;
|
if (!solution) return false;
|
||||||
|
|
||||||
const option = words.find((w) => {
|
const option = words.find((w) => {
|
||||||
if (typeof w === "string") {
|
if (typeof w === "string") {
|
||||||
return w.toLowerCase() === x.solution.toLowerCase();
|
return w.toLowerCase() === x.solution.toLowerCase();
|
||||||
} else if ('letter' in w) {
|
} else if ("letter" in w) {
|
||||||
return w.word.toLowerCase() === x.solution.toLowerCase();
|
return w.letter.toLowerCase() === x.solution.toLowerCase();
|
||||||
} else {
|
} else {
|
||||||
return w.id.toString() === x.id.toString();
|
return w.id.toString() === x.id.toString();
|
||||||
}
|
}
|
||||||
@@ -44,9 +33,9 @@ export default function FillBlanksSolutions({
|
|||||||
|
|
||||||
if (typeof option === "string") {
|
if (typeof option === "string") {
|
||||||
return solution.toLowerCase() === option.toLowerCase();
|
return solution.toLowerCase() === option.toLowerCase();
|
||||||
} else if ('letter' in option) {
|
} else if ("letter" in option) {
|
||||||
return solution.toLowerCase() === option.word.toLowerCase();
|
return solution.toLowerCase() === option.word.toLowerCase();
|
||||||
} else if ('options' in option) {
|
} else if ("options" in option) {
|
||||||
return option.options[solution as keyof typeof option.options] == x.solution;
|
return option.options[solution as keyof typeof option.options] == x.solution;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -55,28 +44,27 @@ export default function FillBlanksSolutions({
|
|||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
|
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
|
||||||
return Array.isArray(words) && words.every(
|
return Array.isArray(words) && words.every((word) => word && typeof word === "object" && "id" in word && "options" in word);
|
||||||
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 = correctUserSolutions!.find((x) => x.id.toString() === id.toString());
|
const userSolution = correctUserSolutions!.find((x) => x.id.toString() === questionId.toString());
|
||||||
const answerSolution = solutions.find(sol => sol.id.toString() === id.toString())!.solution;
|
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;
|
let answerText;
|
||||||
if (typeCheckWordsMC(words)) {
|
if (typeCheckWordsMC(words)) {
|
||||||
const options = words.find((x) => x.id.toString() === id.toString());
|
const options = words.find((x) => x.id.toString() === questionId.toString());
|
||||||
const correctKey = Object.keys(options!.options).find(key =>
|
const correctKey = Object.keys(options!.options).find((key) => key.toLowerCase() === newAnswerSolution);
|
||||||
key.toLowerCase() === answerSolution.toLowerCase()
|
|
||||||
);
|
|
||||||
answerText = options!.options[correctKey as keyof typeof options];
|
answerText = options!.options[correctKey as keyof typeof options];
|
||||||
} else {
|
} else {
|
||||||
answerText = answerSolution;
|
answerText = answerSolution;
|
||||||
@@ -95,37 +83,34 @@ export default function FillBlanksSolutions({
|
|||||||
const userSolutionWord = words.find((w) =>
|
const userSolutionWord = words.find((w) =>
|
||||||
typeof w === "string"
|
typeof w === "string"
|
||||||
? w.toLowerCase() === userSolution.solution.toLowerCase()
|
? w.toLowerCase() === userSolution.solution.toLowerCase()
|
||||||
: 'letter' in w
|
: "letter" in w
|
||||||
? w.letter.toLowerCase() === userSolution.solution.toLowerCase()
|
? w.letter.toLowerCase() === userSolution.solution.toLowerCase()
|
||||||
: 'options' in w
|
: "options" in w
|
||||||
? w.id === userSolution.id
|
? w.id === userSolution.questionId
|
||||||
: false
|
: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
const userSolutionText =
|
const userSolutionText =
|
||||||
typeof userSolutionWord === "string"
|
typeof userSolutionWord === "string"
|
||||||
? userSolutionWord
|
? userSolutionWord
|
||||||
: userSolutionWord && 'letter' in userSolutionWord
|
: userSolutionWord && "letter" in userSolutionWord
|
||||||
? userSolutionWord.word
|
? userSolutionWord.word
|
||||||
: userSolutionWord && 'options' in userSolutionWord
|
: userSolutionWord && "options" in userSolutionWord
|
||||||
? userSolution.solution
|
? userSolution.solution
|
||||||
: userSolution.solution;
|
: userSolution.solution;
|
||||||
|
|
||||||
let correct;
|
let correct;
|
||||||
let solutionText;
|
let solutionText;
|
||||||
if (typeCheckWordsMC(words)) {
|
if (typeCheckWordsMC(words)) {
|
||||||
const options = words.find((x) => x.id.toString() === id.toString());
|
const options = words.find((x) => x.id.toString() === questionId.toString());
|
||||||
if (options) {
|
if (options) {
|
||||||
const correctKey = Object.keys(options.options).find(key =>
|
const correctKey = Object.keys(options.options).find((key) => key.toLowerCase() === newAnswerSolution);
|
||||||
key.toLowerCase() === answerSolution.toLowerCase()
|
|
||||||
);
|
|
||||||
correct = userSolution.solution == options.options[correctKey as keyof typeof options.options];
|
correct = userSolution.solution == options.options[correctKey as keyof typeof options.options];
|
||||||
solutionText = options.options[correctKey as keyof typeof options.options] || answerSolution;
|
solutionText = options.options[correctKey as keyof typeof options.options] || answerSolution;
|
||||||
} else {
|
} else {
|
||||||
correct = false;
|
correct = false;
|
||||||
solutionText = answerSolution;
|
solutionText = answerSolution;
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
correct = userSolutionText === answerSolution;
|
correct = userSolutionText === answerSolution;
|
||||||
solutionText = answerSolution;
|
solutionText = answerSolution;
|
||||||
@@ -168,7 +153,25 @@ export default function FillBlanksSolutions({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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: correctUserSolutions!, 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: 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">
|
<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">
|
||||||
{correctUserSolutions &&
|
{correctUserSolutions &&
|
||||||
@@ -200,7 +203,8 @@ export default function FillBlanksSolutions({
|
|||||||
color="purple"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onBack({exercise: id, solutions: correctUserSolutions!, 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>
|
||||||
|
|
||||||
@@ -211,6 +215,6 @@ export default function FillBlanksSolutions({
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,10 +27,14 @@ export default function InteractiveSpeaking({
|
|||||||
const [diffNumber, setDiffNumber] = useState(0);
|
const [diffNumber, setDiffNumber] = useState(0);
|
||||||
|
|
||||||
const tooltips: {[key: string]: string} = {
|
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.",
|
"Grammatical Range and Accuracy":
|
||||||
"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.",
|
"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.",
|
||||||
"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.",
|
"Fluency and Coherence":
|
||||||
"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.",
|
"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(() => {
|
||||||
@@ -51,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 &&
|
||||||
@@ -122,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)}`] &&
|
userSolutions[0].evaluation[`transcript_${index + 1}`] &&
|
||||||
userSolutions[0].evaluation[`fixed_text_${(index + 1)}`] && (
|
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))}>
|
onClick={() => setDiffNumber(index + 1)}>
|
||||||
View Correction
|
View Correction
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -144,9 +182,13 @@ export default function InteractiveSpeaking({
|
|||||||
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
|
<div
|
||||||
index === 0 && "tooltip-right"
|
className={clsx(
|
||||||
)} key={key} data-tip={tooltips[key] || "No additional information available"}>
|
"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}
|
{key}: Level {grade}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -178,7 +220,9 @@ export default function InteractiveSpeaking({
|
|||||||
}>
|
}>
|
||||||
Evaluation
|
Evaluation
|
||||||
</Tab>
|
</Tab>
|
||||||
{Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).map((key, index) => (
|
{Object.keys(userSolutions[0].evaluation)
|
||||||
|
.filter((x) => x.startsWith("perfect_answer"))
|
||||||
|
.map((key, index) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={key}
|
key={key}
|
||||||
className={({selected}) =>
|
className={({selected}) =>
|
||||||
@@ -186,10 +230,14 @@ export default function InteractiveSpeaking({
|
|||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
"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",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
selected
|
||||||
|
? "bg-white shadow"
|
||||||
|
: "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||||
)
|
)
|
||||||
}>
|
}>
|
||||||
Recommended Answer<br />(Prompt {index + 1})
|
Recommended Answer
|
||||||
|
<br />
|
||||||
|
(Prompt {index + 1})
|
||||||
</Tab>
|
</Tab>
|
||||||
))}
|
))}
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
@@ -202,10 +250,16 @@ export default function InteractiveSpeaking({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} className="flex flex-col gap-2">
|
<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}>
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit",
|
||||||
|
)}
|
||||||
|
key={key}>
|
||||||
{key}: Level {grade}
|
{key}: Level {grade}
|
||||||
</div>
|
</div>
|
||||||
{typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>}
|
{typeof taskResponse !== "number" && (
|
||||||
|
<span className="px-2 py-2">{taskResponse.comment}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -214,10 +268,15 @@ export default function InteractiveSpeaking({
|
|||||||
<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>
|
||||||
{Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).map((key, index) => (
|
{Object.keys(userSolutions[0].evaluation)
|
||||||
|
.filter((x) => x.startsWith("perfect_answer"))
|
||||||
|
.map((key, index) => (
|
||||||
<Tab.Panel key={key} className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel key={key} 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_${(index + 1)}`].answer.replaceAll(/\s{2,}/g, "\n\n")}
|
{userSolutions[0].evaluation![`perfect_answer_${index + 1}`].answer.replaceAll(
|
||||||
|
/\s{2,}/g,
|
||||||
|
"\n\n",
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
))}
|
))}
|
||||||
@@ -266,6 +325,6 @@ export default function InteractiveSpeaking({
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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({
|
function QuestionSolutionArea({
|
||||||
question,
|
question,
|
||||||
@@ -61,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(
|
||||||
@@ -72,7 +75,25 @@ export default function MatchSentencesSolutions({
|
|||||||
};
|
};
|
||||||
|
|
||||||
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: 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">
|
<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) => (
|
||||||
@@ -112,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>
|
||||||
|
|
||||||
@@ -123,6 +145,6 @@ export default function MatchSentencesSolutions({
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
import {MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap} 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 reactStringReplace from "react-string-replace";
|
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,
|
id,
|
||||||
@@ -17,34 +17,12 @@ function Question({
|
|||||||
}: 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 {userSolutions} = useExamStore((state) => state);
|
||||||
|
|
||||||
const getShuffledOptions = (options: { id: string, text: string }[], questionShuffleMap: ShuffleMap) => {
|
|
||||||
const shuffledOptions = ['A', 'B', 'C', 'D'].map(newId => {
|
|
||||||
const originalId = questionShuffleMap.map[newId];
|
|
||||||
const originalOption = options.find(option => option.id === originalId);
|
|
||||||
return {
|
|
||||||
id: newId,
|
|
||||||
text: originalOption!.text
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return shuffledOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getShuffledSolution = (originalSolution: string, questionShuffleMap: ShuffleMap) => {
|
|
||||||
for (const [newPosition, originalPosition] of Object.entries(questionShuffleMap.map)) {
|
|
||||||
if (originalPosition === originalSolution) {
|
|
||||||
return newPosition;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return originalSolution;
|
|
||||||
}
|
|
||||||
|
|
||||||
const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => {
|
const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => {
|
||||||
if (foundMap) return foundMap;
|
if (foundMap) return foundMap;
|
||||||
return userSolution.shuffleMaps?.find(map => map.id === id) || null;
|
return userSolution.shuffleMaps?.find((map) => map.questionID === id) || null;
|
||||||
}, null as ShuffleMap | null);
|
}, null as ShuffleMap | null);
|
||||||
|
|
||||||
const questionOptions = questionShuffleMap ? getShuffledOptions(options as { id: string, text: string }[], questionShuffleMap) : options;
|
const newSolution = questionShuffleMap ? questionShuffleMap?.map[solution] : solution;
|
||||||
const newSolution = questionShuffleMap ? getShuffledSolution(solution, questionShuffleMap) : solution;
|
|
||||||
|
|
||||||
const renderPrompt = (prompt: string) => {
|
const renderPrompt = (prompt: string) => {
|
||||||
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
|
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
|
||||||
@@ -55,14 +33,14 @@ function Question({
|
|||||||
|
|
||||||
const optionColor = (option: string) => {
|
const optionColor = (option: string) => {
|
||||||
if (option === newSolution && !userSolution) {
|
if (option === newSolution && !userSolution) {
|
||||||
return "!border-mti-gray-davy !text-mti-gray-davy";
|
return "!bg-mti-gray-davy !text-white";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (option === newSolution) {
|
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 (
|
||||||
@@ -70,30 +48,38 @@ function Question({
|
|||||||
{isNaN(Number(id)) ? (
|
{isNaN(Number(id)) ? (
|
||||||
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-lg">
|
<span className="text-lg" key={v4()}>
|
||||||
<>
|
<>
|
||||||
{id} - <span className="text-lg">{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
{id} -{" "}
|
||||||
|
<span className="text-lg" key={v4()}>
|
||||||
|
{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}{" "}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-4 justify-between">
|
<div className="flex flex-wrap gap-4 justify-between">
|
||||||
{variant === "image" &&
|
{variant === "image" &&
|
||||||
questionOptions.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 select-none",
|
"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", newSolution !== option?.id && userSolution !== option?.id && "opacity-50")}>{option?.id}</span>
|
<span className={clsx("text-sm", newSolution !== option?.id && userSolution !== option?.id && "opacity-50")}>
|
||||||
|
{option?.id}
|
||||||
|
</span>
|
||||||
{"src" in option && <img src={option?.src!} alt={`Option ${option?.id}`} />}
|
{"src" in option && <img src={option?.src!} alt={`Option ${option?.id}`} />}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{variant === "text" &&
|
{variant === "text" &&
|
||||||
questionOptions.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-base select-none", optionColor(option!.id))}>
|
className={clsx(
|
||||||
|
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none",
|
||||||
|
optionColor(option!.id),
|
||||||
|
)}>
|
||||||
<span className="font-semibold">{option?.id}.</span>
|
<span className="font-semibold">{option?.id}.</span>
|
||||||
<span>{option?.text}</span>
|
<span>{option?.text}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,22 +92,29 @@ function Question({
|
|||||||
export default function MultipleChoice({id, type, prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
export default function MultipleChoice({id, type, prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
||||||
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
|
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
|
||||||
|
|
||||||
|
const stats = useExamStore((state) => state.userSolutions);
|
||||||
|
|
||||||
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};
|
||||||
};
|
};
|
||||||
|
|
||||||
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(questionIndex + 1);
|
setQuestionIndex(questionIndex + 2);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,14 +122,30 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti
|
|||||||
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(questionIndex - 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
|
||||||
|
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
|
||||||
|
</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>*/}
|
{/*<span className="text-xl font-semibold">{prompt}</span>*/}
|
||||||
{userSolutions && questionIndex < questions.length && (
|
{userSolutions && questionIndex < questions.length && (
|
||||||
<Question
|
<Question
|
||||||
@@ -145,6 +154,17 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 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" />
|
||||||
@@ -162,11 +182,12 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti
|
|||||||
</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
|
||||||
disabled={
|
color="purple"
|
||||||
exam && typeof partIndex !== "undefined" && exam.module === "level" &&
|
variant="outline"
|
||||||
typeof exam.parts[0].intro === "string" && questionIndex === 0 && partIndex === 0}
|
onClick={back}
|
||||||
>
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -174,6 +195,6 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,14 +33,52 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
|
|||||||
}, [userSolutions]);
|
}, [userSolutions]);
|
||||||
|
|
||||||
const tooltips: {[key: string]: string} = {
|
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.",
|
"Grammatical Range and Accuracy":
|
||||||
"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.",
|
"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.",
|
||||||
"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.",
|
"Fluency and Coherence":
|
||||||
"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.",
|
"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 &&
|
||||||
@@ -138,9 +176,13 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
|
|||||||
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
|
<div
|
||||||
index === 0 && "tooltip-right"
|
className={clsx(
|
||||||
)} key={key} data-tip={tooltips[key] || "No additional information available"}>
|
"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}
|
{key}: Level {grade}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -194,10 +236,16 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} className="flex flex-col gap-2">
|
<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}>
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit",
|
||||||
|
)}
|
||||||
|
key={key}>
|
||||||
{key}: Level {grade}
|
{key}: Level {grade}
|
||||||
</div>
|
</div>
|
||||||
{typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>}
|
{typeof taskResponse !== "number" && (
|
||||||
|
<span className="px-2 py-2">{taskResponse.comment}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -261,6 +309,6 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
|
|||||||
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,7 +40,25 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
|||||||
};
|
};
|
||||||
|
|
||||||
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: 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">
|
<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) => (
|
||||||
@@ -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,
|
||||||
@@ -71,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(
|
||||||
@@ -102,7 +105,25 @@ export default function WriteBlanksSolutions({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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: 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">
|
<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) => (
|
||||||
@@ -142,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>
|
||||||
|
|
||||||
@@ -153,6 +175,6 @@ export default function WriteBlanksSolutions({
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,14 +19,52 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
|
|||||||
const aiEval = userSolutions && userSolutions.length > 0 ? userSolutions[0].evaluation?.ai_detection : undefined;
|
const aiEval = userSolutions && userSolutions.length > 0 ? userSolutions[0].evaluation?.ai_detection : undefined;
|
||||||
|
|
||||||
const tooltips: {[key: string]: string} = {
|
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.",
|
"Lexical Resource":
|
||||||
"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.",
|
"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.",
|
||||||
"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.",
|
"Task Achievement":
|
||||||
"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.",
|
"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">
|
||||||
@@ -135,10 +173,13 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
|
|||||||
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx(
|
<div
|
||||||
|
className={clsx(
|
||||||
"bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
|
"bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
|
||||||
index === 0 && "tooltip-right"
|
index === 0 && "tooltip-right",
|
||||||
)} key={key} data-tip={tooltips[key] || "No additional information available"}>
|
)}
|
||||||
|
key={key}
|
||||||
|
data-tip={tooltips[key] || "No additional information available"}>
|
||||||
{key}: Level {grade}
|
{key}: Level {grade}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -204,10 +245,16 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} className="flex flex-col gap-2">
|
<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}>
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 w-fit",
|
||||||
|
)}
|
||||||
|
key={key}>
|
||||||
{key}: Level {grade}
|
{key}: Level {grade}
|
||||||
</div>
|
</div>
|
||||||
{typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>}
|
{typeof taskResponse !== "number" && (
|
||||||
|
<span className="px-2 py-2">{taskResponse.comment}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -273,6 +320,6 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,289 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { BsClock, BsXCircle } from 'react-icons/bs';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import { Stat, User } from '@/interfaces/user';
|
|
||||||
import { Module } 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,
|
|
||||||
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,
|
|
||||||
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 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">
|
|
||||||
<span className={textColor}>
|
|
||||||
Level{" "}
|
|
||||||
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
|
||||||
</span>
|
|
||||||
{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"
|
|
||||||
)}>
|
|
||||||
{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 && "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={examNumber === undefined ? selectExam : undefined}
|
|
||||||
style={{
|
|
||||||
...(width !== undefined && { width }),
|
|
||||||
...(height !== undefined && { height }),
|
|
||||||
}}
|
|
||||||
data-tip="This exam is still being evaluated..."
|
|
||||||
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;
|
|
||||||
@@ -3,6 +3,7 @@ import { Stat } from "@/interfaces/user";
|
|||||||
export interface ITrainingContent {
|
export interface ITrainingContent {
|
||||||
id: string;
|
id: string;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
|
user: string;
|
||||||
exams: {
|
exams: {
|
||||||
id: string;
|
id: string;
|
||||||
date: number;
|
date: number;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import useStats from "@/hooks/useStats";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import {CorporateInformation, CorporateUser, EMPLOYMENT_STATUS, User, Type} 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";
|
||||||
@@ -41,6 +41,7 @@ interface Props {
|
|||||||
onViewStudents?: () => void;
|
onViewStudents?: () => void;
|
||||||
onViewTeachers?: () => void;
|
onViewTeachers?: () => void;
|
||||||
onViewCorporate?: () => void;
|
onViewCorporate?: () => void;
|
||||||
|
maxUserAmount?: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
disabledFields?: {
|
disabledFields?: {
|
||||||
countryManager?: boolean;
|
countryManager?: boolean;
|
||||||
@@ -72,17 +73,34 @@ const CURRENCIES_OPTIONS = CURRENCIES.map(({label, currency}) => ({
|
|||||||
label,
|
label,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers, onViewCorporate, disabled = false, disabledFields = {}}: 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
|
||||||
@@ -92,12 +110,22 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
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 {codes} = useCodes(user.id);
|
||||||
const {permissions} = usePermissions(loggedInUser.id);
|
const {permissions} = usePermissions(loggedInUser.id);
|
||||||
@@ -115,16 +143,27 @@ 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"
|
||||||
? {
|
? {
|
||||||
@@ -134,7 +173,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
corporateInformation:
|
corporateInformation:
|
||||||
type === "corporate"
|
type === "corporate" || type === "mastercorporate"
|
||||||
? {
|
? {
|
||||||
referralAgent,
|
referralAgent,
|
||||||
monthlyDuration,
|
monthlyDuration,
|
||||||
@@ -178,7 +217,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
];
|
];
|
||||||
|
|
||||||
const corporateProfileItems =
|
const corporateProfileItems =
|
||||||
user.type === "corporate"
|
user.type === "corporate" || user.type === "mastercorporate"
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
icon: <BsPerson className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
icon: <BsPerson className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||||
@@ -187,7 +226,7 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsPersonAdd className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
icon: <BsPersonAdd className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||||
value: user.corporateInformation.companyInformation.userAmount,
|
value: user.corporateInformation?.companyInformation?.userAmount,
|
||||||
label: "Number of Users",
|
label: "Number of Users",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -199,7 +238,10 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ProfileSummary user={user} items={user.type === "corporate" ? corporateProfileItems : generalProfileItems} />
|
<ProfileSummary
|
||||||
|
user={user}
|
||||||
|
items={user.type === "corporate" || user.type === "mastercorporate" ? corporateProfileItems : generalProfileItems}
|
||||||
|
/>
|
||||||
|
|
||||||
{user.type === "agent" && (
|
{user.type === "agent" && (
|
||||||
<>
|
<>
|
||||||
@@ -238,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
|
||||||
@@ -248,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"
|
||||||
@@ -266,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>
|
||||||
@@ -277,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(
|
||||||
@@ -305,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>
|
||||||
@@ -384,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"
|
||||||
@@ -409,14 +466,15 @@ 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" && (
|
||||||
|
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
name="passport_id"
|
name="passport_id"
|
||||||
@@ -427,6 +485,16 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
disabled
|
disabled
|
||||||
required
|
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">
|
||||||
@@ -456,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
|
||||||
@@ -472,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">
|
||||||
@@ -526,7 +595,9 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
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 || (!["admin", "developer"].includes(loggedInUser.type) && !!loggedInUser.subscriptionExpirationDate)
|
disabled ||
|
||||||
|
(!["admin", "developer", "mastercorporate", "corporate"].includes(loggedInUser.type) &&
|
||||||
|
!!loggedInUser.subscriptionExpirationDate)
|
||||||
}>
|
}>
|
||||||
Enabled
|
Enabled
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
|
|||||||
@@ -45,16 +45,16 @@ export const PERMISSIONS = {
|
|||||||
updateUser: {
|
updateUser: {
|
||||||
student: {
|
student: {
|
||||||
perm: "editStudent",
|
perm: "editStudent",
|
||||||
list: ["developer", "admin"],
|
list: ["developer", "admin", "corporate", "mastercorporate", "teacher"],
|
||||||
},
|
},
|
||||||
teacher: {
|
teacher: {
|
||||||
perm: "editTeacher",
|
perm: "editTeacher",
|
||||||
list: ["developer", "admin"],
|
list: ["developer", "admin", "corporate", "mastercorporate"],
|
||||||
},
|
},
|
||||||
|
|
||||||
corporate: {
|
corporate: {
|
||||||
perm: "editCorporate",
|
perm: "editCorporate",
|
||||||
list: ["admin", "developer"],
|
list: ["developer", "admin", "mastercorporate"],
|
||||||
},
|
},
|
||||||
mastercorporate: {
|
mastercorporate: {
|
||||||
perm: undefined,
|
perm: undefined,
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -36,7 +36,7 @@ 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();
|
||||||
@@ -45,14 +45,13 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
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
|
||||||
@@ -72,22 +71,22 @@ 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
|
<UserList
|
||||||
user={user}
|
user={user}
|
||||||
|
type="student"
|
||||||
filters={[filter]}
|
filters={[filter]}
|
||||||
renderHeader={(total) => (
|
renderHeader={(total) => (
|
||||||
<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>
|
||||||
@@ -101,22 +100,22 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
|
|
||||||
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
|
<UserList
|
||||||
user={user}
|
user={user}
|
||||||
|
type="teacher"
|
||||||
filters={[filter]}
|
filters={[filter]}
|
||||||
renderHeader={(total) => (
|
renderHeader={(total) => (
|
||||||
<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>
|
||||||
@@ -129,16 +128,14 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AgentsList = () => {
|
const AgentsList = () => {
|
||||||
const filter = (x: User) => x.type === "agent";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserList
|
<UserList
|
||||||
user={user}
|
user={user}
|
||||||
filters={[filter]}
|
type="agent"
|
||||||
renderHeader={(total) => (
|
renderHeader={(total) => (
|
||||||
<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>
|
||||||
@@ -153,11 +150,11 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
const CorporateList = () => (
|
const CorporateList = () => (
|
||||||
<UserList
|
<UserList
|
||||||
user={user}
|
user={user}
|
||||||
filters={[(x) => x.type === "corporate"]}
|
type="corporate"
|
||||||
renderHeader={(total) => (
|
renderHeader={(total) => (
|
||||||
<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>
|
||||||
@@ -170,16 +167,17 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
|
|
||||||
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) => list.includes(x.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserList
|
<UserList
|
||||||
user={user}
|
user={user}
|
||||||
|
type="corporate"
|
||||||
filters={[filter]}
|
filters={[filter]}
|
||||||
renderHeader={(total) => (
|
renderHeader={(total) => (
|
||||||
<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>
|
||||||
@@ -197,11 +195,12 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
return (
|
return (
|
||||||
<UserList
|
<UserList
|
||||||
user={user}
|
user={user}
|
||||||
|
type="agent"
|
||||||
filters={[inactiveCountryManagerFilter]}
|
filters={[inactiveCountryManagerFilter]}
|
||||||
renderHeader={(total) => (
|
renderHeader={(total) => (
|
||||||
<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>
|
||||||
@@ -214,16 +213,17 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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
|
<UserList
|
||||||
user={user}
|
user={user}
|
||||||
|
type="student"
|
||||||
filters={[filter]}
|
filters={[filter]}
|
||||||
renderHeader={(total) => (
|
renderHeader={(total) => (
|
||||||
<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>
|
||||||
@@ -236,16 +236,17 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<UserList
|
<UserList
|
||||||
user={user}
|
user={user}
|
||||||
filters={[filter]}
|
filters={[filter]}
|
||||||
|
type="corporate"
|
||||||
renderHeader={(total) => (
|
renderHeader={(total) => (
|
||||||
<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>
|
||||||
@@ -262,7 +263,7 @@ export default function AdminDashboard({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>
|
||||||
@@ -281,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
|
||||||
@@ -312,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={
|
||||||
@@ -322,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={
|
||||||
@@ -338,9 +339,15 @@ 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}
|
||||||
@@ -352,7 +359,12 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
label="Content Management System (CMS)"
|
label="Content Management System (CMS)"
|
||||||
color="green"
|
color="green"
|
||||||
/>
|
/>
|
||||||
<IconCard onClick={() => setPage("corporatestudentslevels")} Icon={BsPersonFill} label="Corporate Students Levels" color="purple" />
|
<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">
|
||||||
@@ -598,17 +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 === "corporatestudentslevels" && <CorporateStudentsLevelsHelper />}
|
{router.asPath === "/#corporatestudentslevels" && <CorporateStudentsLevelsHelper />}
|
||||||
{page === "" && <DefaultDashboard />}
|
{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";
|
||||||
@@ -23,7 +23,7 @@ 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 {pending, done} = usePaymentStatusUsers();
|
const {pending, done} = usePaymentStatusUsers();
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {usePDFDownload} from "@/hooks/usePDFDownload";
|
|||||||
import {useAssignmentArchive} from "@/hooks/useAssignmentArchive";
|
import {useAssignmentArchive} from "@/hooks/useAssignmentArchive";
|
||||||
import {uniqBy} from "lodash";
|
import {uniqBy} from "lodash";
|
||||||
import {useAssignmentUnarchive} from "@/hooks/useAssignmentUnarchive";
|
import {useAssignmentUnarchive} from "@/hooks/useAssignmentUnarchive";
|
||||||
|
import {useAssignmentRelease} from "@/hooks/useAssignmentRelease";
|
||||||
import {getUserName} from "@/utils/users";
|
import {getUserName} from "@/utils/users";
|
||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
|
|
||||||
@@ -40,11 +41,14 @@ export default function AssignmentCard({
|
|||||||
allowUnarchive,
|
allowUnarchive,
|
||||||
allowExcelDownload,
|
allowExcelDownload,
|
||||||
users,
|
users,
|
||||||
|
released,
|
||||||
}: Assignment & Props) {
|
}: Assignment & Props) {
|
||||||
const renderPdfIcon = usePDFDownload("assignments");
|
const renderPdfIcon = usePDFDownload("assignments");
|
||||||
const renderExcelIcon = usePDFDownload("assignments", "excel");
|
const renderExcelIcon = usePDFDownload("assignments", "excel");
|
||||||
const renderArchiveIcon = useAssignmentArchive(id, reload);
|
const renderArchiveIcon = useAssignmentArchive(id, reload);
|
||||||
const renderUnarchiveIcon = useAssignmentUnarchive(id, reload);
|
const renderUnarchiveIcon = useAssignmentUnarchive(id, reload);
|
||||||
|
const renderReleaseIcon = useAssignmentRelease(id, reload);
|
||||||
|
|
||||||
|
|
||||||
const calculateAverageModuleScore = (module: Module) => {
|
const calculateAverageModuleScore = (module: Module) => {
|
||||||
const resultModuleBandScores = results.map((r) => {
|
const resultModuleBandScores = results.map((r) => {
|
||||||
@@ -58,6 +62,30 @@ export default function AssignmentCard({
|
|||||||
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / results.length;
|
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / results.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const uniqModules = uniqBy(exams, (x) => x.module);
|
||||||
|
|
||||||
|
const shouldRenderPDF = () => {
|
||||||
|
if(released && allowDownload) {
|
||||||
|
// in order to be downloadable, the assignment has to be released
|
||||||
|
// the component should have the allowDownload prop
|
||||||
|
// and the assignment should not have the level module
|
||||||
|
return uniqModules.every(({ module }) => module !== 'level');
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldRenderExcel = () => {
|
||||||
|
if(released && allowExcelDownload) {
|
||||||
|
// in order to be downloadable, the assignment has to be released
|
||||||
|
// the component should have the allowExcelDownload prop
|
||||||
|
// and the assignment should have the level module
|
||||||
|
return uniqModules.some(({ module }) => module === 'level');
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -66,10 +94,11 @@ export default function AssignmentCard({
|
|||||||
<div className="flex flex-row justify-between">
|
<div className="flex flex-row justify-between">
|
||||||
<h3 className="text-xl font-semibold">{name}</h3>
|
<h3 className="text-xl font-semibold">{name}</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{allowDownload && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
{shouldRenderPDF() && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
{allowExcelDownload && renderExcelIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
{shouldRenderExcel() && renderExcelIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
{allowArchive && !archived && renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
{allowArchive && !archived && renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
{allowUnarchive && archived && renderUnarchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
{allowUnarchive && archived && renderUnarchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
|
{!released && renderReleaseIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
@@ -89,7 +118,7 @@ export default function AssignmentCard({
|
|||||||
<span>Assigner: {getUserName(users.find((x) => x.id === assigner))}</span>
|
<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 className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||||
{uniqBy(exams, (x) => x.module).map(({module}) => (
|
{uniqModules.map(({module}) => (
|
||||||
<div
|
<div
|
||||||
key={module}
|
key={module}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
|||||||
@@ -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 {useEffect, 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";
|
||||||
@@ -24,19 +24,31 @@ 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,11 +56,19 @@ 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 [useRandomExams, setUseRandomExams] = useState(true);
|
||||||
const [examIDs, setExamIDs] = useState<{id: string; module: Module}[]>([]);
|
const [examIDs, setExamIDs] = useState<{id: string; module: Module}[]>([]);
|
||||||
|
|
||||||
const {exams} = useExams();
|
const {exams} = useExams();
|
||||||
|
|
||||||
|
const userStudents = useMemo(() => users.filter((x) => x.type === "student"), [users]);
|
||||||
|
const userTeachers = useMemo(() => users.filter((x) => x.type === "teacher"), [users]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module)));
|
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module)));
|
||||||
}, [selectedModules]);
|
}, [selectedModules]);
|
||||||
@@ -62,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);
|
||||||
|
|
||||||
@@ -73,8 +97,12 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
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!`);
|
||||||
@@ -106,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">
|
||||||
@@ -131,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">
|
||||||
@@ -144,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">
|
||||||
@@ -165,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">
|
||||||
@@ -178,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",
|
||||||
@@ -236,13 +277,34 @@ 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>
|
||||||
|
|
||||||
{selectedModules.includes("speaking") && (
|
{selectedModules.includes("speaking") && (
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor's Gender</label>
|
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor's Gender</label>
|
||||||
<Select
|
<Select
|
||||||
value={{value: instructorGender, label: capitalize(instructorGender)}}
|
value={{
|
||||||
|
value: instructorGender,
|
||||||
|
label: capitalize(instructorGender),
|
||||||
|
}}
|
||||||
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)}
|
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)}
|
||||||
disabled={!selectedModules.includes("speaking") || !!assignment}
|
disabled={!selectedModules.includes("speaking") || !!assignment}
|
||||||
options={[
|
options={[
|
||||||
@@ -310,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(
|
||||||
@@ -341,19 +403,88 @@ 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]"
|
||||||
|
color="green"
|
||||||
|
variant="outline"
|
||||||
|
onClick={startAssignment}
|
||||||
|
disabled={isLoading || moment().isAfter(startDate)}
|
||||||
|
isLoading={isLoading}>
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="w-full max-w-[200px]"
|
className="w-full max-w-[200px]"
|
||||||
color="red"
|
color="red"
|
||||||
@@ -363,6 +494,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
isLoading={isLoading}>
|
isLoading={isLoading}>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
disabled={
|
disabled={
|
||||||
|
|||||||
@@ -16,8 +16,15 @@ 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 {
|
||||||
|
BsBook,
|
||||||
|
BsClipboard,
|
||||||
|
BsHeadphones,
|
||||||
|
BsMegaphone,
|
||||||
|
BsPen,
|
||||||
|
} from "react-icons/bs";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
import { futureAssignmentFilter } from "@/utils/assignments";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -39,11 +46,31 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
|
|
||||||
axios
|
axios
|
||||||
.delete(`/api/assignments/${assignment?.id}`)
|
.delete(`/api/assignments/${assignment?.id}`)
|
||||||
.then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`))
|
.then(() =>
|
||||||
|
toast.success(
|
||||||
|
`Successfully deleted the assignment "${assignment?.name}".`
|
||||||
|
)
|
||||||
|
)
|
||||||
.catch(() => toast.error("Something went wrong, please try again later."))
|
.catch(() => toast.error("Something went wrong, please try again later."))
|
||||||
.finally(onClose);
|
.finally(onClose);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startAssignment = () => {
|
||||||
|
if (assignment) {
|
||||||
|
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!");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatTimestamp = (timestamp: string) => {
|
const formatTimestamp = (timestamp: string) => {
|
||||||
const date = moment(parseInt(timestamp));
|
const date = moment(parseInt(timestamp));
|
||||||
const formatter = "YYYY/MM/DD - HH:mm";
|
const formatter = "YYYY/MM/DD - HH:mm";
|
||||||
@@ -57,15 +84,26 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
const resultModuleBandScores = assignment.results.map((r) => {
|
const resultModuleBandScores = assignment.results.map((r) => {
|
||||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||||
|
|
||||||
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
const correct = moduleStats.reduce(
|
||||||
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
(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);
|
return calculateBandScore(correct, total, module, r.type);
|
||||||
});
|
});
|
||||||
|
|
||||||
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
|
return resultModuleBandScores.length === 0
|
||||||
|
? -1
|
||||||
|
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
|
||||||
|
assignment.results.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
const aggregateScoresByModule = (
|
||||||
|
stats: Stat[]
|
||||||
|
): { module: Module; total: number; missing: number; correct: number }[] => {
|
||||||
const scores: {
|
const scores: {
|
||||||
[key in Module]: { total: number; missing: number; correct: number };
|
[key in Module]: { total: number; missing: number; correct: number };
|
||||||
} = {
|
} = {
|
||||||
@@ -109,10 +147,22 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
|
const customContent = (
|
||||||
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
stats: Stat[],
|
||||||
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
user: string,
|
||||||
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
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 aggregatedLevels = aggregatedScores.map((x) => ({
|
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||||
module: x.module,
|
module: x.module,
|
||||||
@@ -122,7 +172,9 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
const timeSpent = stats[0].timeSpent;
|
const timeSpent = stats[0].timeSpent;
|
||||||
|
|
||||||
const selectExam = () => {
|
const selectExam = () => {
|
||||||
const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
|
const examPromises = uniqBy(stats, "exam").map((stat) =>
|
||||||
|
getExamById(stat.module, stat.exam)
|
||||||
|
);
|
||||||
|
|
||||||
Promise.all(examPromises).then((exams) => {
|
Promise.all(examPromises).then((exams) => {
|
||||||
if (exams.every((x) => !!x)) {
|
if (exams.every((x) => !!x)) {
|
||||||
@@ -133,7 +185,7 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
exams
|
exams
|
||||||
.map((x) => x!)
|
.map((x) => x!)
|
||||||
.sort(sortByModule)
|
.sort(sortByModule)
|
||||||
.map((x) => x!.module),
|
.map((x) => x!.module)
|
||||||
);
|
);
|
||||||
router.push("/exercises");
|
router.push("/exercises");
|
||||||
}
|
}
|
||||||
@@ -144,11 +196,15 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
<>
|
<>
|
||||||
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
|
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
|
||||||
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
|
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
|
||||||
<span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
|
<span className="font-medium">
|
||||||
|
{formatTimestamp(stats[0].date.toString())}
|
||||||
|
</span>
|
||||||
{timeSpent && (
|
{timeSpent && (
|
||||||
<>
|
<>
|
||||||
<span className="md:hidden 2xl:flex">• </span>
|
<span className="md:hidden 2xl:flex">• </span>
|
||||||
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
|
<span className="text-sm">
|
||||||
|
{Math.floor(timeSpent / 60)} minutes
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -156,10 +212,16 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
correct / total >= 0.7 && "text-mti-purple",
|
correct / total >= 0.7 && "text-mti-purple",
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||||
correct / total < 0.3 && "text-mti-rose",
|
correct / total < 0.3 && "text-mti-rose"
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
Level{" "}
|
Level{" "}
|
||||||
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
{(
|
||||||
|
aggregatedLevels.reduce(
|
||||||
|
(accumulator, current) => accumulator + current.level,
|
||||||
|
0
|
||||||
|
) / aggregatedLevels.length
|
||||||
|
).toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -174,8 +236,9 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
module === "listening" && "bg-ielts-listening",
|
module === "listening" && "bg-ielts-listening",
|
||||||
module === "writing" && "bg-ielts-writing",
|
module === "writing" && "bg-ielts-writing",
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
module === "level" && "bg-ielts-level",
|
module === "level" && "bg-ielts-level"
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
@@ -202,11 +265,14 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
className={clsx(
|
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",
|
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
|
||||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
correct / total >= 0.3 &&
|
||||||
correct / total < 0.3 && "hover:border-mti-rose",
|
correct / total < 0.7 &&
|
||||||
|
"hover:border-mti-red",
|
||||||
|
correct / total < 0.3 && "hover:border-mti-rose"
|
||||||
)}
|
)}
|
||||||
onClick={selectExam}
|
onClick={selectExam}
|
||||||
role="button">
|
role="button"
|
||||||
|
>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -214,17 +280,30 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"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",
|
"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",
|
||||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
correct / total >= 0.3 &&
|
||||||
correct / total < 0.3 && "hover:border-mti-rose",
|
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."
|
data-tip="Your screen size is too small to view previous exams."
|
||||||
role="button">
|
role="button"
|
||||||
|
>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const shouldRenderStart = () => {
|
||||||
|
if (assignment) {
|
||||||
|
if (futureAssignmentFilter(assignment)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
|
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
|
||||||
<div className="mt-4 flex w-full flex-col gap-4">
|
<div className="mt-4 flex w-full flex-col gap-4">
|
||||||
@@ -233,14 +312,27 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
||||||
className="h-6"
|
className="h-6"
|
||||||
textClassName={
|
textClassName={
|
||||||
(assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"
|
(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
|
||||||
}
|
}
|
||||||
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex items-start gap-8">
|
<div className="flex items-start gap-8">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>
|
||||||
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</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>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span>
|
<span>
|
||||||
@@ -250,7 +342,10 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
.map((u) => `${u.name} (${u.email})`)
|
.map((u) => `${u.name} (${u.email})`)
|
||||||
.join(", ")}
|
.join(", ")}
|
||||||
</span>
|
</span>
|
||||||
<span>Assigner: {getUserName(users.find((x) => x.id === assignment?.assigner))}</span>
|
<span>
|
||||||
|
Assigner:{" "}
|
||||||
|
{getUserName(users.find((x) => x.id === assignment?.assigner))}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -267,15 +362,20 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
module === "listening" && "bg-ielts-listening",
|
module === "listening" && "bg-ielts-listening",
|
||||||
module === "writing" && "bg-ielts-writing",
|
module === "writing" && "bg-ielts-writing",
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
module === "level" && "bg-ielts-level",
|
module === "level" && "bg-ielts-level"
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
{module === "listening" && (
|
||||||
|
<BsHeadphones className="h-4 w-4" />
|
||||||
|
)}
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||||
{calculateAverageModuleScore(module) > -1 && (
|
{calculateAverageModuleScore(module) > -1 && (
|
||||||
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
<span className="text-sm">
|
||||||
|
{calculateAverageModuleScore(module).toFixed(1)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -283,24 +383,47 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xl font-bold">
|
<span className="text-xl font-bold">
|
||||||
Results ({assignment?.results.length}/{assignment?.assignees.length})
|
Results ({assignment?.results.length}/{assignment?.assignees.length}
|
||||||
|
)
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
{assignment && assignment?.results.length > 0 && (
|
{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">
|
<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))}
|
{assignment.results.map((r) =>
|
||||||
|
customContent(r.stats, r.user, r.type)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{assignment && assignment?.results.length === 0 && <span className="ml-1 font-semibold">No results yet...</span>}
|
{assignment && assignment?.results.length === 0 && (
|
||||||
|
<span className="ml-1 font-semibold">No results yet...</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 w-full items-center justify-end">
|
<div className="flex gap-4 w-full items-center justify-end">
|
||||||
{assignment && (assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && (
|
{assignment &&
|
||||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={deleteAssignment}>
|
(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
|
Delete
|
||||||
</Button>
|
</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]">
|
<Button onClick={onClose} className="w-full max-w-[200px]">
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -49,9 +49,13 @@ import {createColumnHelper} from "@tanstack/react-table";
|
|||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import List from "@/components/List";
|
import List from "@/components/List";
|
||||||
import {getUserCompanyName} from "@/resources/user";
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
type StudentPerformanceItem = User & {corporateName: string; group: string};
|
type StudentPerformanceItem = User & {corporateName: string; group: string};
|
||||||
@@ -152,43 +156,40 @@ const StudentPerformanceList = ({items, stats, users}: {items: StudentPerformanc
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CorporateDashboard({user}: Props) {
|
|
||||||
const [page, setPage] = useState("");
|
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 [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
|
|
||||||
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
|
|
||||||
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
|
||||||
const [userBalance, setUserBalance] = useState(0);
|
|
||||||
|
|
||||||
const {stats} = useStats();
|
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
||||||
const {users, reload, isLoading} = useUsers();
|
|
||||||
const {codes} = useCodes(user.id);
|
|
||||||
const {groups} = useGroups({admin: user.id});
|
const {groups} = useGroups({admin: user.id});
|
||||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
|
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: 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 assignmentsUsers = useMemo(
|
||||||
|
() =>
|
||||||
|
[...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(() => {
|
useEffect(() => {
|
||||||
const relatedGroups = groups.filter((x) => x.name === "Students" || x.name === "Teachers" || x.name === "Corporate");
|
setShowModal(!!selectedUser && router.asPath === "/#");
|
||||||
const usersInGroups = relatedGroups.map((x) => x.participants).flat();
|
}, [selectedUser, router.asPath]);
|
||||||
const filteredCodes = codes.filter((x) => !x.userId || !usersInGroups.includes(x.userId));
|
|
||||||
|
|
||||||
setUserBalance(usersInGroups.length + filteredCodes.length);
|
|
||||||
}, [codes, groups]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// in this case it fetches the master corporate account
|
|
||||||
getUserCorporate(user.id).then(setCorporateUserToShow);
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id);
|
|
||||||
const teacherFilter = (user: User) => user.type === "teacher" && 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);
|
||||||
|
|
||||||
@@ -204,64 +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 (
|
|
||||||
<UserList
|
|
||||||
user={user}
|
|
||||||
filters={[filter]}
|
|
||||||
renderHeader={(total) => (
|
|
||||||
<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 ({total})</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<UserList
|
|
||||||
user={user}
|
|
||||||
filters={[filter]}
|
|
||||||
renderHeader={(total) => (
|
|
||||||
<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 ({total})</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@@ -269,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>
|
||||||
@@ -282,153 +225,30 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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 archivedFilter = (a: Assignment) => 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} 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(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}
|
|
||||||
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(pastFilter).length})</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{assignments.filter(pastFilter).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(archivedFilter).length})</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{assignments.filter(archivedFilter).map((a) => (
|
|
||||||
<AssignmentCard
|
|
||||||
{...a}
|
|
||||||
users={users}
|
|
||||||
onClick={() => setSelectedAssignment(a)}
|
|
||||||
key={a.id}
|
|
||||||
allowDownload
|
|
||||||
reload={reloadAssignments}
|
|
||||||
allowUnarchive
|
|
||||||
allowExcelDownload
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const StudentPerformancePage = () => {
|
const StudentPerformancePage = () => {
|
||||||
const students = users
|
const performanceStudents = students.map((u) => ({
|
||||||
.filter((x) => x.type === "student" && groups.flatMap((g) => g.participants).includes(x.id))
|
|
||||||
.map((u) => ({
|
|
||||||
...u,
|
...u,
|
||||||
group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A",
|
group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A",
|
||||||
corporateName: getUserCompanyName(u, users, groups),
|
corporateName: getUserCompanyName(user, [], groups),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full flex justify-between items-center">
|
<div className="w-full flex justify-between items-center">
|
||||||
<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>
|
||||||
<div
|
<div
|
||||||
onClick={reload}
|
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">
|
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>
|
<span>Reload</span>
|
||||||
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
|
<BsArrowRepeat className={clsx("text-xl", isStudentsLoading && "animate-spin")} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StudentPerformanceList items={students} stats={stats} users={users} />
|
<StudentPerformanceList items={performanceStudents} stats={stats} users={students} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -436,7 +256,7 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
const averageLevelCalculator = (studentStats: Stat[]) => {
|
const averageLevelCalculator = (studentStats: Stat[]) => {
|
||||||
const formattedStats = studentStats
|
const formattedStats = studentStats
|
||||||
.map((s) => ({
|
.map((s) => ({
|
||||||
focus: users.find((u) => u.id === s.user)?.focus,
|
focus: students.find((u) => u.id === s.user)?.focus,
|
||||||
score: s.score,
|
score: s.score,
|
||||||
module: s.module,
|
module: s.module,
|
||||||
}))
|
}))
|
||||||
@@ -460,24 +280,26 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
|
|
||||||
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 className="grid grid-cols-5 -md:grid-cols-2 gap-4 text-center">
|
<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
|
||||||
@@ -488,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={`${userBalance}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
|
value={`${balance}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
@@ -507,14 +330,15 @@ export default function CorporateDashboard({user}: Props) {
|
|||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsPersonFillGear}
|
Icon={BsPersonFillGear}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
label="Student Performance"
|
label="Student Performance"
|
||||||
value={users.filter(studentFilter).length}
|
value={students.length}
|
||||||
color="purple"
|
color="purple"
|
||||||
onClick={() => setPage("studentsPerformance")}
|
onClick={() => router.push("/#studentsPerformance")}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
disabled={isAssignmentsLoading}
|
disabled={isAssignmentsLoading}
|
||||||
onClick={() => setPage("assignments")}
|
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">
|
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" />
|
<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">
|
||||||
@@ -530,8 +354,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 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} />
|
||||||
@@ -541,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} />
|
||||||
@@ -552,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} />
|
||||||
@@ -563,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,
|
||||||
@@ -588,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"
|
||||||
@@ -636,12 +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 === "assignments" && <AssignmentsPage />}
|
type="student"
|
||||||
{page === "studentsPerformance" && <StudentPerformancePage />}
|
renderHeader={(total) => (
|
||||||
{page === "" && <DefaultDashboard />}
|
<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 />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React, {useMemo} from "react";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers, { userHashStudent, userHashTeacher, userHashCorporate } from "@/hooks/useUsers";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
@@ -61,29 +61,17 @@ const Card = ({user}: {user: User}) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CorporateStudentsLevels = () => {
|
const CorporateStudentsLevels = () => {
|
||||||
const {users} = useUsers();
|
|
||||||
const {groups} = useGroups({});
|
|
||||||
|
|
||||||
const corporateUsers = users.filter((u) => u.type === "corporate") as User[];
|
|
||||||
const [corporateId, setCorporateId] = React.useState<string>("");
|
const [corporateId, setCorporateId] = React.useState<string>("");
|
||||||
const corporate = corporateUsers.find((u) => u.id === corporateId) || corporateUsers[0];
|
|
||||||
|
|
||||||
const groupsFromCorporate = corporate ? groups.filter((g) => g.admin === corporate.id) : [];
|
const {users: students} = useUsers(userHashStudent);
|
||||||
|
const {users: corporates} = useUsers(userHashCorporate);
|
||||||
|
|
||||||
const groupsParticipants = groupsFromCorporate
|
const corporate = useMemo(() => corporates.find((u) => u.id === corporateId) || corporates[0], [corporates, corporateId]);
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.reduce((accm: User[], p) => {
|
|
||||||
const user = users.find((u) => u.id === p) as User;
|
|
||||||
if (user) {
|
|
||||||
return [...accm, user];
|
|
||||||
}
|
|
||||||
return accm;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Select
|
<Select
|
||||||
options={corporateUsers.map((x: User) => ({
|
options={corporates.map((x: User) => ({
|
||||||
value: x.id,
|
value: x.id,
|
||||||
label: `${x.name} - ${x.email}`,
|
label: `${x.name} - ${x.email}`,
|
||||||
}))}
|
}))}
|
||||||
@@ -98,7 +86,7 @@ const CorporateStudentsLevels = () => {
|
|||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{groupsParticipants.map((u) => (
|
{students.map((u) => (
|
||||||
<Card user={u} key={u.id} />
|
<Card user={u} key={u.id} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -8,14 +8,17 @@ interface Props {
|
|||||||
color: "purple" | "rose" | "red" | "green";
|
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: "text-mti-green-light",
|
green: "mti-green-light",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -24,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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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, MasterCorporateUser, 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, useState, useMemo} from "react";
|
||||||
import {
|
import {
|
||||||
BsArrowLeft,
|
BsArrowLeft,
|
||||||
BsClipboard2Data,
|
BsClipboard2Data,
|
||||||
@@ -52,18 +52,18 @@ import Select from "@/components/Low/Select";
|
|||||||
import {Menu, MenuButton, MenuItem, MenuItems} from "@headlessui/react";
|
import {Menu, MenuButton, MenuItem, MenuItems} from "@headlessui/react";
|
||||||
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
|
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
|
||||||
import MasterStatistical from "./MasterStatistical";
|
import MasterStatistical from "./MasterStatistical";
|
||||||
|
import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments";
|
||||||
|
import useUserBalance from "@/hooks/useUserBalance";
|
||||||
|
import AssignmentsPage from "./views/AssignmentsPage";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: MasterCorporateUser;
|
user: MasterCorporateUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeFilter = (a: Assignment) =>
|
type StudentPerformanceItem = User & {
|
||||||
moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length;
|
corporate?: CorporateUser;
|
||||||
const pastFilter = (a: Assignment) => (moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length) && !a.archived;
|
group?: Group;
|
||||||
const archivedFilter = (a: Assignment) => a.archived;
|
};
|
||||||
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
|
|
||||||
|
|
||||||
type StudentPerformanceItem = User & {corporate?: CorporateUser; group?: Group};
|
|
||||||
const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPerformanceItem[]; stats: Stat[]; users: User[]; groups: Group[]}) => {
|
const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPerformanceItem[]; stats: Stat[]; users: User[]; groups: Group[]}) => {
|
||||||
const [isShowingAmount, setIsShowingAmount] = useState(false);
|
const [isShowingAmount, setIsShowingAmount] = useState(false);
|
||||||
const [availableCorporates] = useState(
|
const [availableCorporates] = useState(
|
||||||
@@ -199,7 +199,6 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
|
|||||||
];
|
];
|
||||||
|
|
||||||
const filterUsers = (data: StudentPerformanceItem[]) => {
|
const filterUsers = (data: StudentPerformanceItem[]) => {
|
||||||
console.log(data, selectedCorporate);
|
|
||||||
const filterByCorporate = (item: StudentPerformanceItem) => item.corporate?.id === selectedCorporate?.id;
|
const filterByCorporate = (item: StudentPerformanceItem) => item.corporate?.id === selectedCorporate?.id;
|
||||||
const filterByGroup = (item: StudentPerformanceItem) => item.group?.id === selectedGroup?.id;
|
const filterByGroup = (item: StudentPerformanceItem) => item.group?.id === selectedGroup?.id;
|
||||||
|
|
||||||
@@ -297,44 +296,56 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function MasterCorporateDashboard({user}: Props) {
|
export default function MasterCorporateDashboard({user}: 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 [corporateAssignments, setCorporateAssignments] = useState<(Assignment & {corporate?: CorporateUser})[]>([]);
|
const [corporateAssignments, setCorporateAssignments] = useState<(Assignment & {corporate?: CorporateUser})[]>([]);
|
||||||
|
|
||||||
const {stats} = useStats();
|
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
||||||
const {users, reload} = useUsers();
|
|
||||||
const {codes} = useCodes(user.id);
|
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 {groups} = useGroups({admin: user.id, userType: user.type});
|
||||||
|
const {balance} = useUserBalance();
|
||||||
|
|
||||||
const masterCorporateUserGroups = [...new Set(groups.filter((u) => u.admin === user.id).flatMap((g) => g.participants))];
|
const users = useMemo(() => uniqBy([...students, ...teachers, ...corporates, user], "id"), [corporates, students, teachers, user]);
|
||||||
|
|
||||||
const corporateUserGroups = [...new Set(groups.flatMap((g) => g.participants))];
|
|
||||||
|
|
||||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
|
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 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]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCorporateAssignments(
|
setCorporateAssignments(
|
||||||
assignments.filter(activeFilter).map((a) => ({
|
assignments.filter(activeAssignmentFilter).map((a) => {
|
||||||
...a,
|
const assigner = [...teachers, ...corporates].find((x) => x.id === a.assigner);
|
||||||
corporate: !!users.find((x) => x.id === a.assigner)
|
|
||||||
? getCorporateUser(users.find((x) => x.id === a.assigner)!, users, groups)
|
|
||||||
: undefined,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}, [assignments, groups, users]);
|
|
||||||
|
|
||||||
const studentFilter = (user: User) => user.type === "student" && corporateUserGroups.includes(user.id);
|
return {
|
||||||
const teacherFilter = (user: User) => user.type === "teacher" && corporateUserGroups.includes(user.id);
|
...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 getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
||||||
|
|
||||||
const UserDisplay = (displayUser: User) => (
|
const UserDisplay = (displayUser: User) => (
|
||||||
@@ -349,81 +360,14 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const StudentsList = () => {
|
const corporateUserFilter = (x: User) => x.type === "corporate";
|
||||||
const filter = (x: User) =>
|
|
||||||
x.type === "student" && (!!selectedUser ? corporateUserGroups.includes(x.id) || false : corporateUserGroups.includes(x.id));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UserList
|
|
||||||
user={user}
|
|
||||||
filters={[filter]}
|
|
||||||
renderHeader={(total) => (
|
|
||||||
<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 ({total})</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TeachersList = () => {
|
|
||||||
const filter = (x: User) =>
|
|
||||||
x.type === "teacher" && (!!selectedUser ? corporateUserGroups.includes(x.id) || false : corporateUserGroups.includes(x.id));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UserList
|
|
||||||
user={user}
|
|
||||||
filters={[filter]}
|
|
||||||
renderHeader={(total) => (
|
|
||||||
<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 ({total})</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const corporateUserFilter = (x: User) =>
|
|
||||||
x.type === "corporate" && (!!selectedUser ? masterCorporateUserGroups.includes(x.id) || false : masterCorporateUserGroups.includes(x.id));
|
|
||||||
|
|
||||||
const CorporateList = () => {
|
|
||||||
return (
|
|
||||||
<UserList
|
|
||||||
user={user}
|
|
||||||
filters={[corporateUserFilter]}
|
|
||||||
renderHeader={(total) => (
|
|
||||||
<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">Corporates ({total})</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const GroupsList = () => {
|
const GroupsList = () => {
|
||||||
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>
|
||||||
@@ -436,33 +380,12 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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 archivedFilter = (a: Assignment) => a.archived;
|
|
||||||
// const futureFilter = (a: Assignment) =>
|
|
||||||
// moment(a.startDate).isAfter(moment());
|
|
||||||
|
|
||||||
const StudentPerformancePage = () => {
|
const StudentPerformancePage = () => {
|
||||||
const students = users
|
|
||||||
.filter((x) => x.type === "student" && groups.flatMap((g) => g.participants).includes(x.id))
|
|
||||||
.map((u) => ({
|
|
||||||
...u,
|
|
||||||
group: groups.find((x) => x.participants.includes(u.id)),
|
|
||||||
corporate: getCorporateUser(u, users, groups),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full flex justify-between items-center">
|
<div className="w-full flex justify-between items-center">
|
||||||
<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>
|
||||||
@@ -474,140 +397,7 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
|
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StudentPerformanceList items={students} stats={stats} users={users} groups={groups} />
|
<StudentPerformanceList items={students} stats={stats} users={corporates} groups={groups} />
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AssignmentsPage = () => {
|
|
||||||
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>
|
|
||||||
<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(activeFilter).reduce((acc, curr) => acc + curr.results.length, 0)}/
|
|
||||||
{assignments.filter(activeFilter).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(activeFilter).length})</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{assignments.filter(activeFilter).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(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}
|
|
||||||
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(pastFilter).length})</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{assignments.filter(pastFilter).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(archivedFilter).length})</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{assignments.filter(archivedFilter).map((a) => (
|
|
||||||
<AssignmentCard
|
|
||||||
{...a}
|
|
||||||
users={users}
|
|
||||||
onClick={() => setSelectedAssignment(a)}
|
|
||||||
key={a.id}
|
|
||||||
allowDownload
|
|
||||||
reload={reloadAssignments}
|
|
||||||
allowUnarchive
|
|
||||||
allowExcelDownload
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -617,20 +407,14 @@ export default function MasterCorporateDashboard({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>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">Master Statistical</h2>
|
<h2 className="text-2xl font-semibold">Master Statistical</h2>
|
||||||
</div>
|
</div>
|
||||||
<MasterStatistical
|
<MasterStatistical users={users} corporateUsers={corporates} />
|
||||||
users={masterCorporateUserGroups.reduce((accm: CorporateUser[], id) => {
|
|
||||||
const user = users.find((u) => u.id === id) as CorporateUser;
|
|
||||||
if (user) return [...accm, user];
|
|
||||||
return accm;
|
|
||||||
}, [])}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -639,17 +423,19 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
<>
|
<>
|
||||||
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center">
|
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center">
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("students")}
|
onClick={() => router.push("/#students")}
|
||||||
Icon={BsPersonFill}
|
Icon={BsPersonFill}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
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")}
|
||||||
Icon={BsPencilSquare}
|
Icon={BsPencilSquare}
|
||||||
|
isLoading={isTeachersLoading}
|
||||||
label="Teachers"
|
label="Teachers"
|
||||||
value={users.filter(teacherFilter).length}
|
value={teachers.length}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
@@ -662,16 +448,16 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
Icon={BsPaperclip}
|
Icon={BsPaperclip}
|
||||||
label="Average Level"
|
label="Average Level"
|
||||||
value={averageLevelCalculator(
|
value={averageLevelCalculator(
|
||||||
users,
|
students,
|
||||||
stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)),
|
stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)),
|
||||||
).toFixed(1)}
|
).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
|
||||||
@@ -683,27 +469,29 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsBank}
|
Icon={BsBank}
|
||||||
label="Corporate"
|
label="Corporate"
|
||||||
value={masterCorporateUserGroups.length}
|
value={corporates.length}
|
||||||
|
isLoading={isCorporatesLoading}
|
||||||
color="purple"
|
color="purple"
|
||||||
onClick={() => setPage("corporate")}
|
onClick={() => router.push("/#corporate")}
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsPersonFillGear}
|
Icon={BsPersonFillGear}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
label="Student Performance"
|
label="Student Performance"
|
||||||
value={users.filter(studentFilter).length}
|
value={students.length}
|
||||||
color="purple"
|
color="purple"
|
||||||
onClick={() => setPage("studentsPerformance")}
|
onClick={() => router.push("/#studentsPerformance")}
|
||||||
/>
|
/>
|
||||||
{/* <IconCard
|
<IconCard
|
||||||
Icon={BsDatabase}
|
Icon={BsDatabase}
|
||||||
label="Master Statistical"
|
label="Master Statistical"
|
||||||
// value={masterCorporateUserGroups.length}
|
// value={masterCorporateUserGroups.length}
|
||||||
color="purple"
|
color="purple"
|
||||||
onClick={() => setPage("statistical")}
|
onClick={() => router.push("/#statistical")}
|
||||||
/> */}
|
/>
|
||||||
<button
|
<button
|
||||||
disabled={isAssignmentsLoading}
|
disabled={isAssignmentsLoading}
|
||||||
onClick={() => setPage("assignments")}
|
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">
|
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" />
|
<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">
|
||||||
@@ -719,8 +507,7 @@ export default function MasterCorporateDashboard({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} />
|
||||||
@@ -730,8 +517,7 @@ export default function MasterCorporateDashboard({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} />
|
||||||
@@ -741,8 +527,7 @@ export default function MasterCorporateDashboard({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} />
|
||||||
@@ -752,8 +537,7 @@ export default function MasterCorporateDashboard({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,
|
||||||
@@ -774,10 +558,17 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
{selectedUser && (
|
{selectedUser && (
|
||||||
<div className="w-full flex flex-col gap-8">
|
<div className="w-full flex flex-col gap-8">
|
||||||
<UserCard
|
<UserCard
|
||||||
|
maxUserAmount={
|
||||||
|
user.type === "mastercorporate"
|
||||||
|
? (user.corporateInformation?.companyInformation?.userAmount || 0) - balance
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
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();
|
||||||
|
if (shouldReload && selectedUser!.type === "corporate") reloadCorporates();
|
||||||
}}
|
}}
|
||||||
onViewStudents={
|
onViewStudents={
|
||||||
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
||||||
@@ -825,14 +616,73 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
</Modal>
|
</Modal>
|
||||||
{page === "students" && <StudentsList />}
|
{router.asPath === "/#students" && (
|
||||||
{page === "teachers" && <TeachersList />}
|
<UserList
|
||||||
{page === "groups" && <GroupsList />}
|
user={user}
|
||||||
{page === "corporate" && <CorporateList />}
|
type="student"
|
||||||
{page === "assignments" && <AssignmentsPage />}
|
renderHeader={(total) => (
|
||||||
{page === "studentsPerformance" && <StudentPerformancePage />}
|
<div className="flex flex-col gap-4">
|
||||||
{page === "statistical" && <MasterStatisticalPage />}
|
<div
|
||||||
{page === "" && <DefaultDashboard />}
|
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 />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,404 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {CorporateUser} from "@/interfaces/user";
|
import { CorporateUser, User } from "@/interfaces/user";
|
||||||
import {BsBank, BsPersonFill} from "react-icons/bs";
|
import { BsFileExcel, BsBank, BsPersonFill } from "react-icons/bs";
|
||||||
import IconCard from "./IconCard";
|
import IconCard from "./IconCard";
|
||||||
|
|
||||||
import useAssignmentsCorporates from "@/hooks/useAssignmentCorporates";
|
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 {
|
interface Props {
|
||||||
users: CorporateUser[];
|
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 MasterStatistical = (props: Props) => {
|
||||||
const {users} = props;
|
const { users, corporateUsers } = props;
|
||||||
|
|
||||||
const usersList = React.useMemo(() => users.map((x) => x.id), [users]);
|
const corporateRelevantUsers = React.useMemo(
|
||||||
|
() => corporateUsers.filter((x) => x.type !== "student") as CorporateUser[],
|
||||||
|
[corporateUsers]
|
||||||
|
);
|
||||||
|
|
||||||
const {assignments} = useAssignmentsCorporates({corporates: usersList});
|
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 (
|
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">
|
<div className="flex flex-wrap gap-2 items-center text-center">
|
||||||
<IconCard Icon={BsBank} label="Consolidate" value={0} color="purple" onClick={() => console.log("clicked")} />
|
<IconCard
|
||||||
{users.map((group) => (
|
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
|
<IconCard
|
||||||
key={group.id}
|
key={group.id}
|
||||||
Icon={BsBank}
|
Icon={BsBank}
|
||||||
label={group.corporateInformation?.companyInformation?.name}
|
label={group.corporateInformation?.companyInformation?.name}
|
||||||
value={0}
|
value={getConsolidateScoreStr(corporateScores[group.id])}
|
||||||
color="purple"
|
color="purple"
|
||||||
onClick={() => console.log("clicked", group)}
|
onClick={() => {
|
||||||
|
if (isSelected) {
|
||||||
|
setSelectedCorporates(
|
||||||
|
selectedCorporates.filter((x) => x !== group.id)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedCorporates([...selectedCorporates, group.id]);
|
||||||
|
}}
|
||||||
|
isSelected={isSelected}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
<IconCard onClick={() => console.log("clicked")} Icon={BsPersonFill} label="Consolidate Highest Student" color="purple" />
|
})}
|
||||||
</div>
|
</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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,18 @@ import ProgressBar from "@/components/Low/ProgressBar";
|
|||||||
import InviteCard from "@/components/Medium/InviteCard";
|
import InviteCard from "@/components/Medium/InviteCard";
|
||||||
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 {countExamModules, countFullExams, 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";
|
||||||
@@ -23,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, !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);
|
||||||
@@ -47,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));
|
||||||
|
|
||||||
@@ -72,11 +76,13 @@ 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
|
||||||
@@ -122,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) && (
|
||||||
@@ -173,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())}
|
|
||||||
className="h-full w-full !rounded-xl"
|
|
||||||
variant="outline">
|
|
||||||
Start
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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
|
<Button
|
||||||
disabled={moment(assignment.startDate).isAfter(moment())}
|
className={clsx("w-full h-full !rounded-xl")}
|
||||||
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
|
||||||
onClick={() => startAssignment(assignment)}
|
onClick={() => startAssignment(assignment)}
|
||||||
variant="outline">
|
variant="outline"
|
||||||
|
disabled={sessions.filter((x) => x.assignment?.id === assignment.id).length > 0}>
|
||||||
Start
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{assignment.results.map((r) => r.user).includes(user.id) && (
|
{assignment.results.map((r) => r.user).includes(user.id) && (
|
||||||
@@ -243,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>
|
||||||
@@ -252,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,
|
||||||
@@ -48,34 +48,48 @@ import AssignmentView from "./AssignmentView";
|
|||||||
import {getUserCorporate} from "@/utils/groups";
|
import {getUserCorporate} from "@/utils/groups";
|
||||||
import {checkAccess} from "@/utils/permissions";
|
import {checkAccess} from "@/utils/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
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({adminAdmins: user.id});
|
||||||
const {permissions} = usePermissions(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);
|
||||||
|
|
||||||
@@ -91,35 +105,6 @@ 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 (
|
|
||||||
<UserList
|
|
||||||
user={user}
|
|
||||||
filters={[filter]}
|
|
||||||
renderHeader={(total) => (
|
|
||||||
<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 ({total})</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const GroupsList = () => {
|
const GroupsList = () => {
|
||||||
const filter = (x: Group) => x.admin === user.id;
|
const filter = (x: Group) => x.admin === user.id;
|
||||||
|
|
||||||
@@ -127,7 +112,7 @@ export default function TeacherDashboard({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>
|
||||||
@@ -143,7 +128,7 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
const averageLevelCalculator = (studentStats: Stat[]) => {
|
const averageLevelCalculator = (studentStats: Stat[]) => {
|
||||||
const formattedStats = studentStats
|
const formattedStats = studentStats
|
||||||
.map((s) => ({
|
.map((s) => ({
|
||||||
focus: users.find((u) => u.id === s.user)?.focus,
|
focus: students.find((u) => u.id === s.user)?.focus,
|
||||||
score: s.score,
|
score: s.score,
|
||||||
module: s.module,
|
module: s.module,
|
||||||
}))
|
}))
|
||||||
@@ -165,144 +150,24 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
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 archivedFilter = (a: Assignment) => 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)
|
|
||||||
: 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} 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(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}
|
|
||||||
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(pastFilter).length})</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{assignments.filter(pastFilter).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(archivedFilter).length})</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{assignments.filter(archivedFilter).map((a) => (
|
|
||||||
<AssignmentCard
|
|
||||||
{...a}
|
|
||||||
users={users}
|
|
||||||
onClick={() => setSelectedAssignment(a)}
|
|
||||||
key={a.id}
|
|
||||||
allowDownload
|
|
||||||
reload={reloadAssignments}
|
|
||||||
allowUnarchive
|
|
||||||
allowExcelDownload
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</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
|
||||||
@@ -314,6 +179,7 @@ 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"
|
||||||
/>
|
/>
|
||||||
@@ -323,11 +189,11 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
label="Groups"
|
label="Groups"
|
||||||
value={groups.filter((x) => x.admin === user.id).length}
|
value={groups.filter((x) => x.admin === user.id).length}
|
||||||
color="purple"
|
color="purple"
|
||||||
onClick={() => setPage("groups")}
|
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">
|
||||||
@@ -341,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} />
|
||||||
@@ -352,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} />
|
||||||
@@ -363,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,
|
||||||
@@ -388,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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,13 +4,14 @@ 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 {
|
import {
|
||||||
BsArrowCounterclockwise,
|
BsArrowCounterclockwise,
|
||||||
|
BsBan,
|
||||||
BsBook,
|
BsBook,
|
||||||
BsClipboard,
|
BsClipboard,
|
||||||
BsClipboardFill,
|
BsClipboardFill,
|
||||||
@@ -26,6 +27,8 @@ import {capitalize} from "lodash";
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import {UserSolution} from "@/interfaces/exam";
|
import {UserSolution} from "@/interfaces/exam";
|
||||||
import ai_usage from "@/utils/ai.detection";
|
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;
|
||||||
@@ -44,17 +47,18 @@ interface Props {
|
|||||||
};
|
};
|
||||||
solutions: UserSolution[];
|
solutions: UserSolution[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
assignment?: Assignment;
|
||||||
onViewResults: (moduleIndex?: number) => void;
|
onViewResults: (moduleIndex?: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Finish({user, scores, modules, information, solutions, 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 [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false);
|
||||||
|
|
||||||
const aiUsage = Math.round(ai_usage(solutions) * 100);
|
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]);
|
||||||
|
|
||||||
@@ -94,10 +98,10 @@ export default function Finish({user, scores, modules, information, solutions, i
|
|||||||
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -165,13 +169,11 @@ export default function Finish({user, scores, modules, information, solutions, i
|
|||||||
<span className="font-semibold">Writing</span>
|
<span className="font-semibold">Writing</span>
|
||||||
</div>
|
</div>
|
||||||
{aiUsage >= 50 && user.type !== "student" && (
|
{aiUsage >= 50 && user.type !== "student" && (
|
||||||
<div className={clsx(
|
<div
|
||||||
"flex items-center justify-center border px-3 h-full rounded",
|
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-orange-100 border-orange-400 text-orange-700': aiUsage < 80,
|
"bg-red-100 border-red-400 text-red-700": aiUsage >= 80,
|
||||||
'bg-red-100 border-red-400 text-red-700': aiUsage >= 80,
|
})}>
|
||||||
}
|
|
||||||
)}>
|
|
||||||
<span className="text-xs">AI Usage</span>
|
<span className="text-xs">AI Usage</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -210,7 +212,18 @@ export default function Finish({user, scores, modules, information, solutions, i
|
|||||||
</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">
|
||||||
@@ -283,6 +296,7 @@ export default function Finish({user, scores, modules, information, solutions, i
|
|||||||
<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>
|
||||||
@@ -290,6 +304,7 @@ export default function Finish({user, scores, modules, information, solutions, i
|
|||||||
</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
|
||||||
|
disabled={assignment && !assignment.released}
|
||||||
onClick={() => onViewResults(modules.findIndex((x) => x === selectedModule))}
|
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">
|
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" />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import { LevelPart, UserSolution } from "@/interfaces/exam";
|
import { LevelPart, UserSolution } from "@/interfaces/exam";
|
||||||
|
import clsx from "clsx";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
|
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
|
||||||
|
|
||||||
@@ -21,10 +22,10 @@ const PartDivider: React.FC<Props> = ({ partIndex, part, onNext }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-3/6 h-fit border bg-white rounded-3xl p-12 gap-8">
|
<div className={clsx("flex flex-col h-fit border bg-white rounded-3xl p-12 gap-8", part.intro ? "w-3/6" : "items-center my-auto")}>
|
||||||
{/** only level for now */}
|
{/** only level for now */}
|
||||||
<div className="flex flex-row gap-4 items-center"><div className="w-12 h-12 bg-ielts-level flex items-center justify-center rounded-lg">{moduleIcon["level"]}</div><p className="text-3xl">{`Part ${partIndex + 1}`}</p></div>
|
<div className="flex flex-row gap-4 items-center"><div className="w-12 h-12 bg-ielts-level flex items-center justify-center rounded-lg">{moduleIcon["level"]}</div><p className="text-3xl">{part.intro ? `Part ${partIndex + 1}` : "Placement Test"}</p></div>
|
||||||
{part.intro!.split('\\n\\n').map((x, index) => <p key={`line-${index}`} className="text-2xl text-clip">{x}</p>)}
|
{part.intro && part.intro.split('\\n\\n').map((x, index) => <p key={`line-${index}`} className="text-2xl text-clip" dangerouslySetInnerHTML={{__html: x.replace('that is not correct', 'that is <span class="font-bold"><u>not correct</u></span>')}}></p>)}
|
||||||
<div className="flex items-center justify-center mt-4">
|
<div className="flex items-center justify-center mt-4">
|
||||||
<Button color="purple" onClick={() => onNext()} className="max-w-[200px] self-end w-full text-2xl">
|
<Button color="purple" onClick={() => onNext()} className="max-w-[200px] self-end w-full text-2xl">
|
||||||
{partIndex === 0 ? `Start now`: `Start Part ${partIndex + 1}`}
|
{partIndex === 0 ? `Start now`: `Start Part ${partIndex + 1}`}
|
||||||
|
|||||||
155
src/exams/Level/Shuffle.ts
Normal file
155
src/exams/Level/Shuffle.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { Exercise, FillBlanksExercise, FillBlanksMCOption, MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap, Shuffles, UserSolution } from "@/interfaces/exam";
|
||||||
|
|
||||||
|
export default function shuffleExamExercise(
|
||||||
|
shuffle: boolean | undefined,
|
||||||
|
exercise: Exercise,
|
||||||
|
showSolutions: boolean,
|
||||||
|
userSolutions: UserSolution[],
|
||||||
|
shuffles: Shuffles[],
|
||||||
|
setShuffles: (maps: Shuffles[]) => void
|
||||||
|
): Exercise {
|
||||||
|
if (!shuffle) {
|
||||||
|
return exercise;
|
||||||
|
}
|
||||||
|
const userSolution = userSolutions.find((x) => x.exercise === exercise.id)!;
|
||||||
|
|
||||||
|
if (exercise.type === "multipleChoice") {
|
||||||
|
return shuffleMultipleChoice(exercise, userSolution, shuffles, setShuffles, showSolutions);
|
||||||
|
} else if (exercise.type === "fillBlanks") {
|
||||||
|
return shuffleFillBlanks(exercise, userSolution, shuffles, setShuffles, showSolutions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return exercise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffleMultipleChoice(
|
||||||
|
exercise: MultipleChoiceExercise,
|
||||||
|
userSolution: UserSolution,
|
||||||
|
shuffles: Shuffles[],
|
||||||
|
setShuffles: (maps: Shuffles[]) => void,
|
||||||
|
showSolutions: boolean,
|
||||||
|
): MultipleChoiceExercise {
|
||||||
|
|
||||||
|
if (typeof userSolution.shuffleMaps === "undefined" || (userSolution.shuffleMaps && userSolution.shuffleMaps.length === 0) && !showSolutions) {
|
||||||
|
const newShuffleMaps: ShuffleMap[] = [];
|
||||||
|
exercise.questions = exercise.questions.map(shuffleQuestion(newShuffleMaps));
|
||||||
|
userSolution!.shuffleMaps = newShuffleMaps;
|
||||||
|
setShuffles([...shuffles.filter((x) => x.exerciseID !== exercise.id), {exerciseID: exercise.id, shuffles: newShuffleMaps}]);
|
||||||
|
} else {
|
||||||
|
exercise.questions = exercise.questions.map(retrieveShuffledQuestion(userSolution.shuffleMaps));
|
||||||
|
}
|
||||||
|
|
||||||
|
return exercise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffleQuestion(newShuffleMaps: ShuffleMap[]) {
|
||||||
|
return (question: MultipleChoiceQuestion): MultipleChoiceQuestion => {
|
||||||
|
const options = [...question.options];
|
||||||
|
const shuffledOptions = fisherYatesShuffle(options);
|
||||||
|
|
||||||
|
const optionMapping: Record<string, string> = {};
|
||||||
|
const newOptions = shuffledOptions.map((option, index) => {
|
||||||
|
const newId = String.fromCharCode(65 + index);
|
||||||
|
optionMapping[option.id] = newId;
|
||||||
|
return { ...option, id: newId };
|
||||||
|
});
|
||||||
|
|
||||||
|
newShuffleMaps.push({ questionID: question.id, map: optionMapping });
|
||||||
|
|
||||||
|
return { ...question, options: newOptions, shuffleMap: optionMapping };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function retrieveShuffledQuestion(shuffleMaps: ShuffleMap[]) {
|
||||||
|
return (question: MultipleChoiceQuestion): MultipleChoiceQuestion => {
|
||||||
|
const questionShuffleMap = shuffleMaps.find(map => map.questionID === question.id);
|
||||||
|
if (questionShuffleMap) {
|
||||||
|
const shuffledOptions = Object.entries(questionShuffleMap.map)
|
||||||
|
.sort(([, a], [, b]) => a.localeCompare(b))
|
||||||
|
.map(([originalId, newId]) => {
|
||||||
|
const originalOption = question.options.find(opt => opt.id === originalId);
|
||||||
|
return { ...originalOption, id: newId };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...question, options: shuffledOptions, shuffleMap: questionShuffleMap.map };
|
||||||
|
}
|
||||||
|
return question;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function shuffleFillBlanks(
|
||||||
|
exercise: FillBlanksExercise,
|
||||||
|
userSolution: UserSolution,
|
||||||
|
shuffles: Shuffles[],
|
||||||
|
setShuffles: (maps: Shuffles[]) => void,
|
||||||
|
showSolutions: boolean
|
||||||
|
): FillBlanksExercise {
|
||||||
|
if (typeof userSolution.shuffleMaps === "undefined" || (userSolution.shuffleMaps && userSolution.shuffleMaps.length === 0) && !showSolutions) {
|
||||||
|
const newShuffleMaps: ShuffleMap[] = [];
|
||||||
|
exercise.words = exercise.words.map(shuffleWord(newShuffleMaps));
|
||||||
|
userSolution.shuffleMaps = newShuffleMaps;
|
||||||
|
setShuffles([...shuffles.filter((x) => x.exerciseID !== exercise.id), {exerciseID: exercise.id, shuffles: newShuffleMaps}]);
|
||||||
|
} else {
|
||||||
|
exercise.words = exercise.words.map(retrieveShuffledWord(userSolution.shuffleMaps!));
|
||||||
|
}
|
||||||
|
|
||||||
|
return exercise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffleWord(newShuffleMaps: ShuffleMap[]) {
|
||||||
|
return (word: string | { letter: string; word: string } | FillBlanksMCOption): typeof word => {
|
||||||
|
if (typeof word === 'object' && 'options' in word) {
|
||||||
|
const options = word.options;
|
||||||
|
const originalKeys = Object.keys(options);
|
||||||
|
const shuffledKeys = fisherYatesShuffle(originalKeys);
|
||||||
|
|
||||||
|
const newOptions = shuffledKeys.reduce<typeof options>((acc, key, index) => {
|
||||||
|
acc[key as keyof typeof options] = options[originalKeys[index] as keyof typeof options];
|
||||||
|
return acc;
|
||||||
|
}, {} as typeof options);
|
||||||
|
|
||||||
|
const optionMapping = originalKeys.reduce<Record<string, string>>((acc, key, index) => {
|
||||||
|
acc[key] = shuffledKeys[index];
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
newShuffleMaps.push({ questionID: word.id, map: optionMapping });
|
||||||
|
|
||||||
|
return { ...word, options: newOptions };
|
||||||
|
}
|
||||||
|
return word;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function retrieveShuffledWord(shuffleMaps: ShuffleMap[]) {
|
||||||
|
return (word: string | { letter: string; word: string } | FillBlanksMCOption): typeof word => {
|
||||||
|
if (typeof word === 'object' && 'options' in word) {
|
||||||
|
const shuffleMap = shuffleMaps.find(map => map.questionID === word.id);
|
||||||
|
if (shuffleMap) {
|
||||||
|
const options = word.options;
|
||||||
|
const shuffledOptions = Object.keys(options).reduce<typeof options>((acc, key) => {
|
||||||
|
const shuffledKey = shuffleMap.map[key as keyof typeof options];
|
||||||
|
acc[shuffledKey as keyof typeof options] = options[key as keyof typeof options];
|
||||||
|
return acc;
|
||||||
|
}, {} as typeof options);
|
||||||
|
|
||||||
|
return { ...word, options: shuffledOptions };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return word;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fisherYatesShuffle<T>(array: T[]): T[] {
|
||||||
|
const shuffled = [...array];
|
||||||
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||||
|
}
|
||||||
|
return shuffled;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
|
||||||
|
return Array.isArray(words) && words.every(
|
||||||
|
word => word && typeof word === 'object' && 'id' in word && 'options' in word
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,88 +1,145 @@
|
|||||||
import { LevelPart } from "@/interfaces/exam";
|
import { LevelPart } from "@/interfaces/exam";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
part: LevelPart,
|
part: LevelPart,
|
||||||
contextWord: string | undefined,
|
contextWords: { match: string, originalLine: string }[] | undefined,
|
||||||
setContextWordLine: React.Dispatch<React.SetStateAction<number | undefined>>
|
setContextWordLines: React.Dispatch<React.SetStateAction<number[] | undefined>>
|
||||||
|
setTotalLines: React.Dispatch<React.SetStateAction<number>>
|
||||||
}
|
}
|
||||||
|
|
||||||
const TextComponent: React.FC<Props> = ({part, contextWord, setContextWordLine}) => {
|
const TextComponent: React.FC<Props> = ({ part, contextWords, setContextWordLines, setTotalLines }) => {
|
||||||
const textRef = useRef<HTMLDivElement>(null);
|
const textRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [lineNumbers, setLineNumbers] = useState<number[]>([]);
|
||||||
|
const [lineHeight, setLineHeight] = useState<number>(0);
|
||||||
|
const [addBreaksTo, setAddBreaksTo] = useState<number[]>([]);
|
||||||
|
|
||||||
|
const getBoldTag = (context: string) => {
|
||||||
|
const regex = /<b\s+class=['"]([^'"]+)['"]>(\d+)<\/b>/;
|
||||||
|
const match = context.match(regex);
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
className: match[1],
|
||||||
|
number: match[2],
|
||||||
|
fullTag: match[0]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bTag = getBoldTag(part.context!);
|
||||||
|
|
||||||
const calculateLineNumbers = () => {
|
const calculateLineNumbers = () => {
|
||||||
if (textRef.current) {
|
if (textRef.current) {
|
||||||
const computedStyle = window.getComputedStyle(textRef.current);
|
const computedStyle = window.getComputedStyle(textRef.current);
|
||||||
|
const lineHeightValue = parseFloat(computedStyle.lineHeight);
|
||||||
const containerWidth = textRef.current.clientWidth;
|
const containerWidth = textRef.current.clientWidth;
|
||||||
|
setLineHeight(lineHeightValue);
|
||||||
|
|
||||||
const offscreenElement = document.createElement('div');
|
const offscreenElement = document.createElement('div');
|
||||||
offscreenElement.style.position = 'absolute';
|
offscreenElement.style.position = 'absolute';
|
||||||
offscreenElement.style.top = '-9999px';
|
offscreenElement.style.top = '-9999px';
|
||||||
offscreenElement.style.left = '-9999px';
|
offscreenElement.style.left = '-9999px';
|
||||||
|
offscreenElement.style.whiteSpace = 'pre-wrap';
|
||||||
offscreenElement.style.width = `${containerWidth}px`;
|
offscreenElement.style.width = `${containerWidth}px`;
|
||||||
offscreenElement.style.font = computedStyle.font;
|
offscreenElement.style.font = computedStyle.font;
|
||||||
offscreenElement.style.lineHeight = computedStyle.lineHeight;
|
offscreenElement.style.lineHeight = computedStyle.lineHeight;
|
||||||
offscreenElement.style.whiteSpace = 'pre-wrap';
|
|
||||||
offscreenElement.style.wordWrap = 'break-word';
|
offscreenElement.style.wordWrap = 'break-word';
|
||||||
offscreenElement.style.textAlign = computedStyle.textAlign as CanvasTextAlign;
|
offscreenElement.style.textAlign = computedStyle.textAlign as CanvasTextAlign;
|
||||||
|
|
||||||
const paragraphs = part.context!.split('\n\n');
|
const textContent = textRef.current.textContent || '';
|
||||||
let currentLine = 1;
|
|
||||||
let contextWordLine: number | null = null;
|
|
||||||
const paragraphLineStarts: number[] = [];
|
|
||||||
|
|
||||||
paragraphs.forEach((paragraph, pIndex) => {
|
const paragraphs = textContent.split(/\n\n/);
|
||||||
const p = document.createElement('p');
|
const betweenParagraphs: string[][] = Array.from({ length: paragraphs.length }, () => []);
|
||||||
p.style.margin = '0';
|
|
||||||
p.style.padding = '0';
|
|
||||||
|
|
||||||
paragraph.split(/(\s+)/).forEach((word: string) => {
|
const lines = paragraphs.map((line, lineIndex) => {
|
||||||
const span = document.createElement('span');
|
const paragraphWords = line.split(/(\s+)/);
|
||||||
span.textContent = word;
|
return paragraphWords.map((word, wordIndex) => {
|
||||||
p.appendChild(span);
|
if (lineIndex !== 0 && wordIndex == 0 && lineIndex < paragraphs.length) {
|
||||||
});
|
betweenParagraphs[lineIndex - 1][1] = word;
|
||||||
|
|
||||||
offscreenElement.appendChild(p);
|
|
||||||
|
|
||||||
if (pIndex < paragraphs.length - 1) {
|
|
||||||
const gap = document.createElement('div');
|
|
||||||
gap.style.height = '16px'; // gap-4
|
|
||||||
offscreenElement.appendChild(gap);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (wordIndex == paragraphWords.length - 1 && lineIndex < paragraphs.length) {
|
||||||
|
betweenParagraphs[lineIndex][0] = word;
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = document.createElement('span');
|
||||||
|
if (wordIndex === 0 && bTag) {
|
||||||
|
const b = document.createElement('b');
|
||||||
|
b.classList.add(bTag.className);
|
||||||
|
b.textContent = `${lineIndex + 1}`;
|
||||||
|
span.appendChild(b);
|
||||||
|
span.appendChild(document.createTextNode(word.substring(1)));
|
||||||
|
}else {
|
||||||
|
span.appendChild(document.createTextNode(word));
|
||||||
|
}
|
||||||
|
return span;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
lines.forEach(line => {
|
||||||
|
line.forEach((span, index) => {
|
||||||
|
offscreenElement.appendChild(span);
|
||||||
|
});
|
||||||
|
offscreenElement.appendChild(document.createElement('br'));
|
||||||
});
|
});
|
||||||
|
|
||||||
document.body.appendChild(offscreenElement);
|
document.body.appendChild(offscreenElement);
|
||||||
|
|
||||||
|
const processedLines: string[][] = [[]];
|
||||||
|
let currentLine = 1;
|
||||||
let currentLineTop: number | undefined;
|
let currentLineTop: number | undefined;
|
||||||
const elements = offscreenElement.querySelectorAll('p, div');
|
|
||||||
|
|
||||||
elements.forEach((element) => {
|
let contextWordLines: number[] = [];
|
||||||
if (element.tagName === 'P') {
|
if (contextWords) {
|
||||||
const spans = element.querySelectorAll<HTMLSpanElement>('span');
|
contextWordLines = Array(contextWords.length).fill(-1);
|
||||||
paragraphLineStarts.push(currentLine);
|
}
|
||||||
|
const firstChild = offscreenElement.firstChild as HTMLElement;
|
||||||
|
if (firstChild) {
|
||||||
|
currentLineTop = firstChild.getBoundingClientRect().top;
|
||||||
|
}
|
||||||
|
|
||||||
spans.forEach(span => {
|
const spans = offscreenElement.querySelectorAll<HTMLSpanElement>('span');
|
||||||
|
|
||||||
|
let betweenIndex = 0;
|
||||||
|
const addBreaksTo: number[] = [];
|
||||||
|
spans.forEach((span, index) => {
|
||||||
const rect = span.getBoundingClientRect();
|
const rect = span.getBoundingClientRect();
|
||||||
const top = rect.top;
|
const top = rect.top;
|
||||||
|
|
||||||
if (currentLineTop === undefined || top > currentLineTop) {
|
if (
|
||||||
if (currentLineTop !== undefined) {
|
betweenIndex < paragraphs.length - 1 &&
|
||||||
currentLine++;
|
span.textContent === betweenParagraphs[betweenIndex][1] &&
|
||||||
}
|
spans[index - 1].textContent === betweenParagraphs[betweenIndex][0]
|
||||||
currentLineTop = top;
|
) {
|
||||||
|
addBreaksTo.push(currentLine);
|
||||||
|
betweenIndex = betweenIndex + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contextWord && contextWordLine === null && span.textContent?.includes(contextWord)) {
|
if (currentLineTop !== undefined && top > currentLineTop) {
|
||||||
contextWordLine = currentLine;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (element.tagName === 'DIV') { // Gap
|
|
||||||
currentLine++;
|
currentLine++;
|
||||||
currentLineTop = undefined;
|
currentLineTop = top;
|
||||||
|
processedLines.push([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
processedLines[processedLines.length - 1].push(span.textContent?.trim() || '');
|
||||||
|
if (contextWords && contextWordLines.some(element => element === -1)) {
|
||||||
|
contextWords.forEach((w, index) => {
|
||||||
|
if (span.textContent?.includes(w.match) && contextWordLines[index] == -1) {
|
||||||
|
contextWordLines[index] = currentLine;
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (contextWordLine) {
|
|
||||||
setContextWordLine(contextWordLine);
|
setAddBreaksTo(addBreaksTo);
|
||||||
|
|
||||||
|
setLineNumbers(processedLines.map((_, index) => index + 1));
|
||||||
|
setTotalLines(currentLine);
|
||||||
|
|
||||||
|
if (contextWordLines.length > 0) {
|
||||||
|
setContextWordLines(contextWordLines);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.body.removeChild(offscreenElement);
|
document.body.removeChild(offscreenElement);
|
||||||
@@ -90,7 +147,6 @@ const TextComponent: React.FC<Props> = ({part, contextWord, setContextWordLine})
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
calculateLineNumbers();
|
calculateLineNumbers();
|
||||||
|
|
||||||
@@ -110,34 +166,23 @@ const TextComponent: React.FC<Props> = ({part, contextWord, setContextWordLine})
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [part.context, contextWord]);
|
}, [part.context, contextWords]);
|
||||||
|
|
||||||
/*if (typeof part.showContextLines === "undefined") {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 w-full">
|
<div className="flex mt-2">
|
||||||
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
<div className="flex-shrink-0 w-8 pr-2">
|
||||||
{!!part.context &&
|
{lineNumbers.map(num => (
|
||||||
part.context
|
<>
|
||||||
.split(/\n|(\\n)/g)
|
<div key={num} className="text-gray-400 flex justify-end" style={{ lineHeight: `${lineHeight}px` }}>
|
||||||
.filter((x) => x && x.length > 0 && x !== "\\n")
|
{num}
|
||||||
.map((line, index) => (
|
</div>
|
||||||
<Fragment key={index}>
|
{/* Do not delete the space between the span or else the lines get messed up */}
|
||||||
<p key={index}>{line}</p>
|
{addBreaksTo.includes(num) && <span className={`h-[${lineHeight}px] whitespace-pre-wrap`}> </span>}
|
||||||
</Fragment>
|
</>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
<div ref={textRef} className="h-fit whitespace-pre-wrap ml-2">
|
||||||
}*/
|
<div dangerouslySetInnerHTML={{ __html: part.context! }} />
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2 w-full">
|
|
||||||
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
|
||||||
<div className="flex mt-2">
|
|
||||||
<div ref={textRef} className="h-fit ml-2 flex flex-col gap-4">
|
|
||||||
{part.context!.split('\n\n').map((line, index) => {
|
|
||||||
return <p key={`line-${index}`}><span className="mr-6">{index + 1}</span>{line}</p>
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,15 +4,17 @@ import Button from "@/components/Low/Button";
|
|||||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
import { renderSolution } from "@/components/Solutions";
|
import { renderSolution } from "@/components/Solutions";
|
||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import { Exercise, FillBlanksMCOption, LevelExam, MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap, UserSolution } from "@/interfaces/exam";
|
import { Exercise, FillBlanksMCOption, LevelExam, MultipleChoiceExercise, UserSolution } from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import { countExercises } from "@/utils/moduleUtils";
|
import { countExercises } from "@/utils/moduleUtils";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { use, useEffect, useState } from "react";
|
import { use, useEffect, useMemo, useState } from "react";
|
||||||
import TextComponent from "./TextComponent";
|
import TextComponent from "./TextComponent";
|
||||||
import PartDivider from "./PartDivider";
|
import PartDivider from "./PartDivider";
|
||||||
import Timer from "@/components/Medium/Timer";
|
import Timer from "@/components/Medium/Timer";
|
||||||
import { Stat } from "@/interfaces/user";
|
import shuffleExamExercise from "./Shuffle";
|
||||||
|
import { Tab } from "@headlessui/react";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
exam: LevelExam;
|
exam: LevelExam;
|
||||||
@@ -31,236 +33,198 @@ const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
|
|||||||
|
|
||||||
export default function Level({ exam, showSolutions = false, onFinish, editing = false }: Props) {
|
export default function Level({ exam, showSolutions = false, onFinish, editing = false }: Props) {
|
||||||
const levelBgColor = "bg-ielts-level-light";
|
const levelBgColor = "bg-ielts-level-light";
|
||||||
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]);
|
|
||||||
const [showQuestionsModal, setShowQuestionsModal] = useState(false);
|
|
||||||
|
|
||||||
const { setBgColor } = useExamStore((state) => state);
|
const {
|
||||||
const { userSolutions, setUserSolutions } = useExamStore((state) => state);
|
userSolutions,
|
||||||
const { hasExamEnded, setHasExamEnded } = useExamStore((state) => state);
|
hasExamEnded,
|
||||||
const { partIndex, setPartIndex } = useExamStore((state) => state);
|
partIndex,
|
||||||
const { exerciseIndex, setExerciseIndex } = useExamStore((state) => state);
|
exerciseIndex,
|
||||||
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]);
|
questionIndex,
|
||||||
const [shuffleMaps, setShuffleMaps] = useExamStore((state) => [state.shuffleMaps, state.setShuffleMaps])
|
shuffles,
|
||||||
const [currentExercise, setCurrentExercise] = useState<Exercise>();
|
currentSolution,
|
||||||
|
setBgColor,
|
||||||
|
setUserSolutions,
|
||||||
|
setHasExamEnded,
|
||||||
|
setPartIndex,
|
||||||
|
setExerciseIndex,
|
||||||
|
setQuestionIndex,
|
||||||
|
setShuffles,
|
||||||
|
setCurrentSolution
|
||||||
|
} = useExamStore((state) => state);
|
||||||
|
|
||||||
|
// In case client want to switch back
|
||||||
|
const textRenderDisabled = true;
|
||||||
|
|
||||||
|
const [showSubmissionModal, setShowSubmissionModal] = useState(false);
|
||||||
|
const [showQuestionsModal, setShowQuestionsModal] = useState(false);
|
||||||
|
const [continueAnyways, setContinueAnyways] = useState(false);
|
||||||
|
const [textRender, setTextRender] = useState(false);
|
||||||
|
const [changedPrompt, setChangedPrompt] = useState(false);
|
||||||
|
const [nextExerciseCalled, setNextExerciseCalled] = useState(false);
|
||||||
|
const [currentSolutionSet, setCurrentSolutionSet] = useState(false);
|
||||||
|
|
||||||
|
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.parts.map((_, index) => index) : [0]));
|
||||||
|
|
||||||
|
const [questionModalKwargs, setQuestionModalKwargs] = useState<{
|
||||||
|
type?: "module" | "blankQuestions" | "submit"; unanswered?: boolean | undefined; onClose: (next?: boolean) => void | undefined;
|
||||||
|
}>({
|
||||||
|
type: "blankQuestions",
|
||||||
|
onClose: function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } }
|
||||||
|
});
|
||||||
|
|
||||||
|
const [currentExercise, setCurrentExercise] = useState<Exercise | undefined>(undefined);
|
||||||
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions);
|
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions);
|
||||||
|
const [startNow, setStartNow] = useState<boolean>(true && !showSolutions);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentExercise === undefined && partIndex === 0 && exerciseIndex === 0) {
|
||||||
|
setCurrentExercise(exam.parts[0].exercises[0]);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentExercise, partIndex, exerciseIndex]);
|
||||||
|
|
||||||
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));
|
||||||
|
|
||||||
const [contextWord, setContextWord] = useState<string | undefined>(undefined);
|
const [contextWords, setContextWords] = useState<{ match: string, originalLine: string }[] | undefined>(undefined);
|
||||||
const [contextWordLine, setContextWordLine] = useState<number | undefined>(undefined);
|
const [contextWordLines, setContextWordLines] = useState<number[] | undefined>(undefined);
|
||||||
|
const [totalLines, setTotalLines] = useState<number>(0);
|
||||||
|
|
||||||
|
const [showSolutionsSave, setShowSolutionsSave] = useState(showSolutions ? userSolutions.filter((x) => x.module === "level") : undefined)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showSolutions && exerciseIndex && exam.shuffle && userSolutions[exerciseIndex].shuffleMaps) {
|
if (typeof currentSolution !== "undefined") {
|
||||||
setShuffleMaps(userSolutions[exerciseIndex].shuffleMaps as ShuffleMap[])
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== currentSolution.exercise), { ...currentSolution, module: "level" as Module, exam: exam.id, shuffleMaps: exam.shuffle ? [...shuffles.find((x) => x.exerciseID == currentExercise?.id)?.shuffles!] : [] }]);
|
||||||
|
setCurrentSolutionSet(true);
|
||||||
}
|
}
|
||||||
}, [showSolutions, exerciseIndex, setShuffleMaps, userSolutions, exam.shuffle])
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentSolution, exam.id, exam.shuffle, shuffles, currentExercise])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded && exerciseIndex === -1) {
|
if (typeof currentSolution !== "undefined") {
|
||||||
setExerciseIndex(exerciseIndex + 1);
|
setCurrentSolution(undefined);
|
||||||
}
|
}
|
||||||
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentSolution]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showSolutions) {
|
||||||
|
const solutionShuffles = userSolutions.map(solution => ({
|
||||||
|
exerciseID: solution.exercise,
|
||||||
|
shuffles: solution.shuffleMaps || []
|
||||||
|
}));
|
||||||
|
setShuffles(solutionShuffles);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
const getExercise = () => {
|
const getExercise = () => {
|
||||||
let exercise = exam.parts[partIndex]?.exercises[exerciseIndex];
|
let exercise = exam.parts[partIndex]?.exercises[exerciseIndex];
|
||||||
if (!exercise) return undefined;
|
|
||||||
|
|
||||||
exercise = {
|
exercise = {
|
||||||
...exercise,
|
...exercise,
|
||||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
userSolutions: userSolutions.find((x) => x.exercise == exercise.id)?.solutions || [],
|
||||||
};
|
};
|
||||||
|
exercise = shuffleExamExercise(exam.shuffle, exercise, showSolutions, userSolutions, shuffles, setShuffles);
|
||||||
if (exam.shuffle && exercise.type === "multipleChoice" && !showSolutions) {
|
|
||||||
console.log("Shuffling MC ");
|
|
||||||
const exerciseShuffles = userSolutions[exerciseIndex].shuffleMaps;
|
|
||||||
if (exerciseShuffles && exerciseShuffles.length == 0) {
|
|
||||||
const newShuffleMaps: ShuffleMap[] = [];
|
|
||||||
|
|
||||||
exercise.questions = exercise.questions.map(question => {
|
|
||||||
const options = [...question.options];
|
|
||||||
let shuffledOptions = [...options].sort(() => Math.random() - 0.5);
|
|
||||||
|
|
||||||
const newOptions = options.map((option, index) => ({
|
|
||||||
id: option.id,
|
|
||||||
text: shuffledOptions[index].text
|
|
||||||
}));
|
|
||||||
|
|
||||||
const optionMapping = options.reduce<{ [key: string]: string }>((acc, originalOption) => {
|
|
||||||
const shuffledPosition = newOptions.find(newOpt => newOpt.text === originalOption.text)?.id;
|
|
||||||
if (shuffledPosition) {
|
|
||||||
acc[shuffledPosition] = originalOption.id;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
newShuffleMaps.push({ id: question.id, map: optionMapping });
|
|
||||||
|
|
||||||
return { ...question, options: newOptions };
|
|
||||||
});
|
|
||||||
|
|
||||||
setShuffleMaps(newShuffleMaps);
|
|
||||||
} else {
|
|
||||||
console.log("retrieving MC shuffles");
|
|
||||||
exercise.questions = exercise.questions.map(question => {
|
|
||||||
const questionShuffleMap = shuffleMaps.find(map => map.id === question.id);
|
|
||||||
if (questionShuffleMap) {
|
|
||||||
const newOptions = question.options.map(option => ({
|
|
||||||
id: option.id,
|
|
||||||
text: question.options.find(o => questionShuffleMap.map[o.id] === option.id)?.text || option.text
|
|
||||||
}));
|
|
||||||
return { ...question, options: newOptions };
|
|
||||||
}
|
|
||||||
return question;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (exam.shuffle && exercise.type === "fillBlanks" && typeCheckWordsMC(exercise.words) && !showSolutions) {
|
|
||||||
if (shuffleMaps.length === 0 && !showSolutions) {
|
|
||||||
const newShuffleMaps: ShuffleMap[] = [];
|
|
||||||
console.log("Shuffling Words");
|
|
||||||
exercise.words = exercise.words.map(word => {
|
|
||||||
if ('options' in word) {
|
|
||||||
const options = { ...word.options };
|
|
||||||
const originalKeys = Object.keys(options);
|
|
||||||
const shuffledKeys = [...originalKeys].sort(() => Math.random() - 0.5);
|
|
||||||
|
|
||||||
const newOptions = shuffledKeys.reduce((acc, key, index) => {
|
|
||||||
acc[key as keyof typeof options] = options[originalKeys[index] as keyof typeof options];
|
|
||||||
return acc;
|
|
||||||
}, {} as { [key in keyof typeof options]: string });
|
|
||||||
|
|
||||||
const optionMapping = originalKeys.reduce((acc, key, index) => {
|
|
||||||
acc[key as keyof typeof options] = shuffledKeys[index];
|
|
||||||
return acc;
|
|
||||||
}, {} as { [key in keyof typeof options]: string });
|
|
||||||
|
|
||||||
newShuffleMaps.push({ id: word.id, map: optionMapping });
|
|
||||||
|
|
||||||
return { ...word, options: newOptions };
|
|
||||||
}
|
|
||||||
return word;
|
|
||||||
});
|
|
||||||
|
|
||||||
setShuffleMaps(newShuffleMaps);
|
|
||||||
} else {
|
|
||||||
console.log("Retrieving Words shuffle");
|
|
||||||
exercise.words = exercise.words.map(word => {
|
|
||||||
if ('options' in word) {
|
|
||||||
const shuffleMap = shuffleMaps.find(map => map.id === word.id);
|
|
||||||
if (shuffleMap) {
|
|
||||||
const options = { ...word.options };
|
|
||||||
const shuffledOptions = Object.keys(options).reduce((acc, key) => {
|
|
||||||
const shuffledKey = shuffleMap.map[key as keyof typeof options];
|
|
||||||
acc[shuffledKey as keyof typeof options] = options[key as keyof typeof options];
|
|
||||||
return acc;
|
|
||||||
}, {} as { [key in keyof typeof options]: string });
|
|
||||||
|
|
||||||
return { ...word, options: shuffledOptions };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return word;
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(exercise);
|
|
||||||
return exercise;
|
return exercise;
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (exerciseIndex !== -1) {
|
|
||||||
setCurrentExercise(getExercise());
|
setCurrentExercise(getExercise());
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [partIndex, exerciseIndex, shuffleMaps, exam.parts[partIndex].context]);
|
}, [partIndex, exerciseIndex, questionIndex]);
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
useEffect(() => {
|
setNextExerciseCalled(true);
|
||||||
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
|
|
||||||
if (exerciseIndex !== -1 && currentExercise && currentExercise.type === "multipleChoice" && currentExercise.questions[storeQuestionIndex].prompt) {
|
|
||||||
const match = currentExercise.questions[storeQuestionIndex].prompt.match(regex);
|
|
||||||
if (match) {
|
|
||||||
const word = match[1];
|
|
||||||
const originalLineNumber = match[2];
|
|
||||||
|
|
||||||
if (word !== contextWord) {
|
|
||||||
setContextWord(word);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedPrompt = currentExercise.questions[storeQuestionIndex].prompt.replace(
|
const nextExercise = () => {
|
||||||
`in line ${originalLineNumber}`,
|
|
||||||
`in line ${contextWordLine || originalLineNumber}`
|
|
||||||
);
|
|
||||||
|
|
||||||
currentExercise.questions[storeQuestionIndex].prompt = updatedPrompt;
|
|
||||||
} else {
|
|
||||||
setContextWord(undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [currentExercise, storeQuestionIndex]);
|
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
if (solution) {
|
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*if (storeQuestionIndex > 0 || currentExercise?.type == "fillBlanks") {
|
|
||||||
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex }]);
|
|
||||||
}*/
|
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||||
setExerciseIndex(exerciseIndex + 1);
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
|
setCurrentSolutionSet(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded && (showQuestionsModal || showSolutions)) {
|
if (partIndex + 1 === exam.parts.length && !hasExamEnded && !showQuestionsModal && !showSolutions && !continueAnyways) {
|
||||||
if (!showSolutions && exam.parts[0].intro) {
|
modalKwargs();
|
||||||
|
setShowQuestionsModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
||||||
|
if (!answeredEveryQuestion(partIndex) && !continueAnyways && !showSolutions && !seenParts.has(partIndex + 1)) {
|
||||||
|
modalKwargs();
|
||||||
|
setShowQuestionsModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showSolutions && exam.parts[0].intro && !seenParts.has(partIndex + 1)) {
|
||||||
setShowPartDivider(true);
|
setShowPartDivider(true);
|
||||||
setBgColor(levelBgColor);
|
setBgColor(levelBgColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSeenParts(prev => new Set(prev).add(partIndex + 1));
|
||||||
|
|
||||||
|
if (partIndex < exam.parts.length - 1 && exam.parts[partIndex + 1].context && !textRenderDisabled) {
|
||||||
|
setTextRender(true);
|
||||||
|
}
|
||||||
setPartIndex(partIndex + 1);
|
setPartIndex(partIndex + 1);
|
||||||
setExerciseIndex(!!exam.parts[partIndex + 1].context ? -1 : 0);
|
setExerciseIndex(0);
|
||||||
setStoreQuestionIndex(0);
|
setQuestionIndex(0);
|
||||||
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex }]);
|
setCurrentSolutionSet(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded && !showQuestionsModal && !showSolutions) {
|
if (partIndex + 1 === exam.parts.length && exerciseIndex === exam.parts[partIndex].exercises.length - 1 && !continueAnyways) {
|
||||||
|
modalKwargs();
|
||||||
setShowQuestionsModal(true);
|
setShowQuestionsModal(true);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
solution &&
|
|
||||||
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
|
|
||||||
(x) => x === 0,
|
|
||||||
) &&
|
|
||||||
!showSolutions &&
|
|
||||||
!editing &&
|
|
||||||
!hasExamEnded
|
|
||||||
) {
|
|
||||||
setShowQuestionsModal(true);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setHasExamEnded(false);
|
setHasExamEnded(false);
|
||||||
|
setCurrentSolutionSet(false);
|
||||||
if (solution) {
|
if (typeof showSolutionsSave !== "undefined") {
|
||||||
let stat = { ...solution, module: "level" as Module, exam: exam.id }
|
onFinish(showSolutionsSave);
|
||||||
if (exam.shuffle) {
|
|
||||||
stat.shuffleMaps = shuffleMaps
|
|
||||||
}
|
|
||||||
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...stat }]);
|
|
||||||
} else {
|
} else {
|
||||||
onFinish(userSolutions);
|
onFinish(userSolutions);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (nextExerciseCalled && currentSolutionSet) {
|
||||||
|
nextExercise();
|
||||||
|
setNextExerciseCalled(false);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [nextExerciseCalled, currentSolutionSet])
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
if (solution) {
|
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]);
|
if (exam.parts[partIndex].context && questionIndex === 0 && !textRender && !textRenderDisabled) {
|
||||||
|
setTextRender(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (questionIndex == 0) {
|
||||||
|
setPartIndex(partIndex - 1);
|
||||||
|
if (!seenParts.has(partIndex - 1)) {
|
||||||
|
setBgColor(levelBgColor);
|
||||||
|
setShowPartDivider(true);
|
||||||
|
setQuestionIndex(0);
|
||||||
|
setSeenParts(prev => new Set(prev).add(partIndex - 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1;
|
||||||
|
const lastExercise = exam.parts[partIndex - 1].exercises[lastExerciseIndex];
|
||||||
|
setExerciseIndex(lastExerciseIndex);
|
||||||
|
|
||||||
|
if (lastExercise.type === "multipleChoice") {
|
||||||
|
setQuestionIndex(lastExercise.questions.length - 1)
|
||||||
|
} else {
|
||||||
|
setQuestionIndex(0)
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setExerciseIndex(exerciseIndex - 1);
|
setExerciseIndex(exerciseIndex - 1);
|
||||||
@@ -269,171 +233,293 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
|||||||
const lastPartExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1;
|
const lastPartExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1;
|
||||||
const previousExercise = exam.parts[partIndex - 1].exercises[lastPartExerciseIndex];
|
const previousExercise = exam.parts[partIndex - 1].exercises[lastPartExerciseIndex];
|
||||||
if (previousExercise.type === "multipleChoice") {
|
if (previousExercise.type === "multipleChoice") {
|
||||||
setStoreQuestionIndex(previousExercise.questions.length - 1)
|
setQuestionIndex(previousExercise.questions.length - 1)
|
||||||
}
|
}
|
||||||
const multipleChoiceQuestionsDone = [];
|
|
||||||
for (let i = 0; i < exam.parts.length; i++) {
|
|
||||||
if (i == (partIndex - 1)) break;
|
|
||||||
for (let j = 0; j < exam.parts[i].exercises.length; j++) {
|
|
||||||
const exercise = exam.parts[i].exercises[j];
|
|
||||||
if (exercise.type === "multipleChoice") {
|
|
||||||
multipleChoiceQuestionsDone.push({ id: exercise.id, amount: exercise.questions.length - 1 })
|
|
||||||
}
|
|
||||||
if (exercise.type === "fillBlanks") {
|
|
||||||
multipleChoiceQuestionsDone.push({ id: exercise.id, amount: exercise.words.length - 1 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setMultipleChoicesDone(multipleChoiceQuestionsDone);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (exerciseIndex === -1) {
|
|
||||||
nextExercise()
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [exerciseIndex])
|
|
||||||
|
|
||||||
const calculateExerciseIndex = () => {
|
const calculateExerciseIndex = () => {
|
||||||
if (partIndex === 0) {
|
return exam.parts.reduce((acc, curr, index) => {
|
||||||
return (
|
if (index < partIndex) {
|
||||||
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex //+ multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
|
return acc + countExercises(curr.exercises)
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
|
return acc;
|
||||||
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
|
}, 0) + (questionIndex + 1);
|
||||||
return (
|
|
||||||
exercisesDone +
|
|
||||||
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
|
|
||||||
storeQuestionIndex
|
|
||||||
+ multipleChoicesDone.reduce((acc, curr) => { return acc + curr.amount }, 0)
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderText = () => (
|
const renderText = () => (
|
||||||
|
<>
|
||||||
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative py-8 px-16")}>
|
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative py-8 px-16")}>
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col w-full gap-2">
|
<div className="flex flex-col w-full gap-2">
|
||||||
|
{textRender && !textRenderDisabled ? (
|
||||||
|
<>
|
||||||
<h4 className="text-xl font-semibold">
|
<h4 className="text-xl font-semibold">
|
||||||
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
||||||
</h4>
|
</h4>
|
||||||
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
|
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
|
||||||
</div>
|
</>
|
||||||
|
) : (
|
||||||
|
<h4 className="text-xl font-semibold">
|
||||||
|
Answer the questions on the right based on what you've read.
|
||||||
|
</h4>
|
||||||
|
)}
|
||||||
|
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
||||||
|
{exam.parts[partIndex].context &&
|
||||||
<TextComponent
|
<TextComponent
|
||||||
part={exam.parts[partIndex]}
|
part={exam.parts[partIndex]}
|
||||||
contextWord={contextWord}
|
contextWords={contextWords}
|
||||||
setContextWordLine={setContextWordLine}
|
setContextWordLines={setContextWordLines}
|
||||||
/>
|
setTotalLines={setTotalLines}
|
||||||
|
/>}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
</div>
|
</div>
|
||||||
|
{textRender && !textRenderDisabled && (
|
||||||
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
onClick={() => { setTextRender(false); previousExercise(); }}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button color="purple" onClick={() => setTextRender(false)} className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const partLabel = () => {
|
const partLabel = () => {
|
||||||
|
const partCategory = exam.parts[partIndex].category ? ` (${exam.parts[partIndex].category})` : '';
|
||||||
if (currentExercise?.type === "fillBlanks" && typeCheckWordsMC(currentExercise.words))
|
if (currentExercise?.type === "fillBlanks" && typeCheckWordsMC(currentExercise.words))
|
||||||
return `Part ${partIndex + 1} (Questions ${currentExercise.words[0].id} - ${currentExercise.words[currentExercise.words.length - 1].id})\n\n${currentExercise.prompt}`
|
return `Part ${partIndex + 1} (Questions ${currentExercise.words[0].id} - ${currentExercise.words[currentExercise.words.length - 1].id})${partCategory}\n\n${currentExercise.prompt}`
|
||||||
|
|
||||||
if (currentExercise?.type === "multipleChoice") {
|
if (currentExercise?.type === "multipleChoice") {
|
||||||
return `Part ${partIndex + 1} (Questions ${currentExercise.questions[0].id} - ${currentExercise.questions[currentExercise.questions.length - 1].id})\n\n${currentExercise.prompt}`
|
return `Part ${partIndex + 1} (Questions ${currentExercise.questions[0].id} - ${currentExercise.questions[currentExercise.questions.length - 1].id})${partCategory}\n\n${currentExercise.prompt}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof exam.parts[partIndex].context === "string") {
|
if (typeof exam.parts[partIndex].context === "string") {
|
||||||
const nextExercise = exam.parts[partIndex].exercises[0] as MultipleChoiceExercise;
|
const nextExercise = exam.parts[partIndex].exercises[0] as MultipleChoiceExercise;
|
||||||
return `Part ${partIndex + 1} (Questions ${nextExercise.questions[0].id} - ${nextExercise.questions[nextExercise.questions.length - 1].id})\n\n${nextExercise.prompt}`
|
return `Part ${partIndex + 1} (Questions ${nextExercise.questions[0].id} - ${nextExercise.questions[nextExercise.questions.length - 1].id})${partCategory}\n\n${nextExercise.prompt}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const modalKwargs = () => {
|
const answeredEveryQuestion = (partIndex: number) => {
|
||||||
const allSolutionsCorrectLength = exam.parts[partIndex].exercises.every((exercise) => {
|
return exam.parts[partIndex].exercises.every((exercise) => {
|
||||||
const userSolution = userSolutions.find(x => x.exercise === exercise.id);
|
const userSolution = userSolutions.find(x => x.exercise === exercise.id);
|
||||||
if (exercise.type === "multipleChoice") {
|
switch (exercise.type) {
|
||||||
|
case 'multipleChoice':
|
||||||
return userSolution?.solutions.length === exercise.questions.length;
|
return userSolution?.solutions.length === exercise.questions.length;
|
||||||
}
|
case 'fillBlanks':
|
||||||
if (exercise.type === "fillBlanks") {
|
|
||||||
return userSolution?.solutions.length === exercise.words.length;
|
return userSolution?.solutions.length === exercise.words.length;
|
||||||
|
case 'writeBlanks':
|
||||||
|
return userSolution?.solutions.length === exercise.solutions.length;
|
||||||
|
case 'matchSentences':
|
||||||
|
return userSolution?.solutions.length === exercise.sentences.length;
|
||||||
|
case 'trueFalse':
|
||||||
|
return userSolution?.solutions.length === exercise.questions.length;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
|
||||||
blankQuestions: !allSolutionsCorrectLength,
|
|
||||||
finishingWhat: "part",
|
|
||||||
onClose: partIndex !== exam.parts.length - 1 ? (
|
|
||||||
function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } }
|
|
||||||
) : function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); onFinish(userSolutions); } else { setShowQuestionsModal(false) } }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
|
||||||
|
|
||||||
|
const findMatch = (index: number) => {
|
||||||
|
if (currentExercise && currentExercise.type === "multipleChoice" && currentExercise!.questions[index]) {
|
||||||
|
const match = currentExercise!.questions[index].prompt.match(regex);
|
||||||
|
if (match) {
|
||||||
|
return { match: match[1], originalLine: match[2] }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the client for some whatever random reason decides
|
||||||
|
// to add more questions update this
|
||||||
|
const numberOfQuestions = 2;
|
||||||
|
|
||||||
|
if (exam.parts[partIndex].context) {
|
||||||
|
const hits = Array.from({ length: numberOfQuestions }).reduce<{ match: string, originalLine: string }[]>((acc, _, i) => {
|
||||||
|
const result = findMatch(questionIndex + i);
|
||||||
|
if (!!result) {
|
||||||
|
acc.push(result);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (hits.length > 0) {
|
||||||
|
setContextWords(hits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentExercise, questionIndex, totalLines]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
exerciseIndex !== -1 && currentExercise &&
|
||||||
|
currentExercise.type === "multipleChoice" &&
|
||||||
|
exam.parts[partIndex].context && contextWordLines
|
||||||
|
) {
|
||||||
|
if (contextWordLines.length > 0) {
|
||||||
|
contextWordLines.forEach((n, i) => {
|
||||||
|
if (contextWords && contextWords[i] && n !== -1) {
|
||||||
|
const updatedPrompt = currentExercise!.questions[questionIndex + i].prompt.replace(
|
||||||
|
`in line ${contextWords[i].originalLine}`,
|
||||||
|
`in line ${n}`
|
||||||
|
);
|
||||||
|
currentExercise!.questions[questionIndex + i].prompt = updatedPrompt;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setChangedPrompt(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [contextWordLines]);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (continueAnyways) {
|
||||||
|
setContinueAnyways(false);
|
||||||
|
nextExercise();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [continueAnyways]);
|
||||||
|
|
||||||
|
const modalKwargs = () => {
|
||||||
|
const kwargs: { type: "module" | "blankQuestions" | "submit", unanswered: boolean, onClose: (next?: boolean) => void; } = {
|
||||||
|
type: "blankQuestions",
|
||||||
|
unanswered: false,
|
||||||
|
onClose: function (x: boolean | undefined) { if (x) { setContinueAnyways(true); setShowQuestionsModal(false); } else { setShowQuestionsModal(false) } }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (partIndex === exam.parts.length - 1) {
|
||||||
|
kwargs.type = "submit"
|
||||||
|
kwargs.unanswered = !exam.parts.every((_, partIndex) => answeredEveryQuestion(partIndex));
|
||||||
|
kwargs.onClose = function (x: boolean | undefined) { if (x) { setShowSubmissionModal(true); setShowQuestionsModal(false); } else { setShowQuestionsModal(false) } };
|
||||||
|
}
|
||||||
|
setQuestionModalKwargs(kwargs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mcNavKwargs = {
|
||||||
|
userSolutions: userSolutions,
|
||||||
|
exam: exam,
|
||||||
|
partIndex: partIndex,
|
||||||
|
showSolutions: showSolutions,
|
||||||
|
"setExerciseIndex": setExerciseIndex,
|
||||||
|
"setPartIndex": setPartIndex,
|
||||||
|
"runOnClick": setQuestionIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const memoizedRender = useMemo(() => {
|
||||||
|
setChangedPrompt(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{textRender && !textRenderDisabled ?
|
||||||
|
renderText() :
|
||||||
|
<>
|
||||||
|
{exam.parts[partIndex].context && renderText()}
|
||||||
|
{(showSolutions || editing) ?
|
||||||
|
currentExercise && renderSolution(currentExercise, nextExercise, previousExercise) :
|
||||||
|
currentExercise && renderExercise(currentExercise, exam.id, next, previousExercise)
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</>)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [textRender, currentExercise, changedPrompt]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={clsx("flex flex-col h-full w-full gap-8 items-center", showPartDivider && "justify-center")}>
|
<div className={clsx("flex flex-col h-full w-full gap-8 items-center", showPartDivider && "justify-center")}>
|
||||||
<QuestionsModal isOpen={showQuestionsModal} {...modalKwargs()} />
|
<Modal
|
||||||
|
className={"!w-2/6 !p-8"}
|
||||||
|
titleClassName={"font-bold text-3xl text-mti-rose-light"}
|
||||||
|
isOpen={showSubmissionModal}
|
||||||
|
onClose={() => { }}
|
||||||
|
title={"Confirm Submission"}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<p className="text-xl mt-8 mb-12">Are you sure you want to proceed with the submission?</p>
|
||||||
|
<div className="w-full flex justify-between">
|
||||||
|
<Button color="purple" onClick={() => setShowSubmissionModal(false)} variant="outline" className="max-w-[200px] self-end w-full !text-xl">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button color="rose" onClick={() => { setShowSubmissionModal(false); setContinueAnyways(true) }} className="max-w-[200px] self-end w-full !text-xl">
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</Modal>
|
||||||
|
<QuestionsModal isOpen={showQuestionsModal} {...questionModalKwargs} />
|
||||||
{
|
{
|
||||||
!(partIndex === 0 && storeQuestionIndex === 0 && showPartDivider) &&
|
!(partIndex === 0 && questionIndex === 0 && (showPartDivider || startNow)) &&
|
||||||
<Timer minTimer={exam.minTimer} disableTimer={showSolutions} standalone={true} />
|
<Timer minTimer={exam.minTimer} disableTimer={showSolutions} standalone={true} />
|
||||||
}
|
}
|
||||||
{exam.parts[0].intro && showPartDivider ? <PartDivider part={exam.parts[partIndex]} partIndex={partIndex} onNext={() => { setShowPartDivider(false); setBgColor("bg-white") }} /> : (
|
{(showPartDivider || startNow) ? <PartDivider part={exam.parts[partIndex]} partIndex={partIndex} onNext={() => { setShowPartDivider(false); setStartNow(false); setBgColor("bg-white"); }} /> : (
|
||||||
<>
|
<>
|
||||||
|
{exam.parts[0].intro && (
|
||||||
|
<div className="w-full">
|
||||||
|
<Tab.Group className="w-[90%]" selectedIndex={partIndex} onChange={setPartIndex}>
|
||||||
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
|
||||||
|
{exam.parts.map((_, index) =>
|
||||||
|
<Tab key={index} onClick={(e) => {
|
||||||
|
/*
|
||||||
|
// If client wants to revert uncomment and remove the added if statement
|
||||||
|
if (!seenParts.has(index)) {
|
||||||
|
e.preventDefault();
|
||||||
|
} else {
|
||||||
|
*/
|
||||||
|
setExerciseIndex(0);
|
||||||
|
setQuestionIndex(0);
|
||||||
|
if (!seenParts.has(index)) {
|
||||||
|
setShowPartDivider(true);
|
||||||
|
setBgColor(levelBgColor);
|
||||||
|
setSeenParts(prev => new Set(prev).add(index));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={({ selected }) =>
|
||||||
|
clsx(
|
||||||
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-level/80",
|
||||||
|
"ring-white ring-opacity-60 focus:outline-none",
|
||||||
|
"transition duration-300 ease-in-out hover:bg-white/70",
|
||||||
|
selected && "bg-white shadow",
|
||||||
|
// seenParts.includes(index) ? "hover:bg-white/70" : "cursor-not-allowed"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>{`Part ${index + 1}`}</Tab>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Tab.List>
|
||||||
|
</Tab.Group>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<ModuleTitle
|
<ModuleTitle
|
||||||
|
examLabel={exam.label}
|
||||||
partLabel={partLabel()}
|
partLabel={partLabel()}
|
||||||
minTimer={exam.minTimer}
|
minTimer={exam.minTimer}
|
||||||
exerciseIndex={calculateExerciseIndex()}
|
exerciseIndex={calculateExerciseIndex()}
|
||||||
module="level"
|
module="level"
|
||||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||||
disableTimer={showSolutions || editing}
|
disableTimer={showSolutions || editing}
|
||||||
showTimer={typeof exam.parts[0].intro === "undefined"}
|
showTimer={false}
|
||||||
|
{...mcNavKwargs}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"mb-20 w-full",
|
"mb-20 w-full",
|
||||||
partIndex > -1 && exerciseIndex > -1 && !!exam.parts[partIndex].context && "grid grid-cols-2 gap-4",
|
!!exam.parts[partIndex].context && !textRender && "grid grid-cols-2 gap-4",
|
||||||
)}>
|
)}>
|
||||||
{partIndex > -1 && !!exam.parts[partIndex].context && renderText()}
|
{memoizedRender}
|
||||||
|
|
||||||
{exerciseIndex > -1 &&
|
|
||||||
partIndex > -1 &&
|
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
|
||||||
!showSolutions &&
|
|
||||||
!editing &&
|
|
||||||
currentExercise &&
|
|
||||||
renderExercise(currentExercise, exam.id, nextExercise, previousExercise)}
|
|
||||||
|
|
||||||
{exerciseIndex > -1 &&
|
|
||||||
partIndex > -1 &&
|
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
|
||||||
(showSolutions || editing) &&
|
|
||||||
currentExercise &&
|
|
||||||
renderSolution(currentExercise, nextExercise, previousExercise)}
|
|
||||||
</div>
|
</div>
|
||||||
{/*exerciseIndex === -1 && partIndex > 0 && (
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
|
||||||
setPartIndex(partIndex - 1);
|
|
||||||
}}
|
|
||||||
className="max-w-[200px] w-full"
|
|
||||||
disabled={
|
|
||||||
exam && typeof partIndex !== "undefined" && exam.module === "level" &&
|
|
||||||
typeof exam.parts[0].intro === "string" && storeQuestionIndex === 0}
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)*/}
|
|
||||||
{exerciseIndex === -1 && partIndex === 0 && (
|
|
||||||
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
|
||||||
Start now
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {User} from "@/interfaces/user";
|
import {Stat, User} from "@/interfaces/user";
|
||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
||||||
import {totalExamsByModule} from "@/utils/stats";
|
import {totalExamsByModule} from "@/utils/stats";
|
||||||
import useStats from "@/hooks/useStats";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import {calculateAverageLevel} from "@/utils/score";
|
import {calculateAverageLevel} from "@/utils/score";
|
||||||
import {sortByModuleName} from "@/utils/moduleUtils";
|
import {sortByModuleName} from "@/utils/moduleUtils";
|
||||||
@@ -30,7 +30,7 @@ export default function Selection({user, page, onStart, disableSelection = false
|
|||||||
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
|
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
|
||||||
const [variant, setVariant] = useState<Variant>("full");
|
const [variant, setVariant] = useState<Variant>("full");
|
||||||
|
|
||||||
const {stats} = useStats(user?.id);
|
const {data: stats} = useFilterRecordsByUser<Stat[]>(user?.id);
|
||||||
const {sessions, isLoading, reload} = useSessions(user.id);
|
const {sessions, isLoading, reload} = useSessions(user.id);
|
||||||
|
|
||||||
const state = useExamStore((state) => state);
|
const state = useExamStore((state) => state);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {initializeApp} from "firebase/app";
|
import {initializeApp} from "firebase/app";
|
||||||
import * as admin from "firebase-admin/app";
|
import * as admin from "firebase-admin/app";
|
||||||
import {getStorage} from "firebase/storage";
|
import {getStorage} from "firebase/storage";
|
||||||
|
import { base64 } from "@firebase/util";
|
||||||
|
|
||||||
const stagingServiceAccount = require("@/constants/staging.json");
|
const stagingServiceAccount = require("@/constants/staging.json");
|
||||||
const platformServiceAccount = require("@/constants/platform.json");
|
const platformServiceAccount = require("@/constants/platform.json");
|
||||||
@@ -22,3 +23,10 @@ export const adminApp = admin.initializeApp(
|
|||||||
Math.random().toString(),
|
Math.random().toString(),
|
||||||
);
|
);
|
||||||
export const storage = getStorage(app);
|
export const storage = getStorage(app);
|
||||||
|
|
||||||
|
export const firebaseAuthScryptParams = {
|
||||||
|
memCost: Number(process.env.FIREBASE_SCRYPT_MEM_COST),
|
||||||
|
rounds: Number(process.env.FIREBASE_SCRYPT_ROUNDS),
|
||||||
|
saltSeparator: process.env.FIREBASE_SCRYPT_B64_SALT_SEPARATOR!,
|
||||||
|
signerKey: process.env.FIREBASE_SCRYPT_B64_SIGNER_KEY!,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { Assignment } from "@/interfaces/results";
|
import { AssignmentWithCorporateId } from "@/interfaces/results";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export default function useAssignmentsCorporates({
|
export default function useAssignmentsCorporates({
|
||||||
corporates,
|
corporates,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
}: {
|
}: {
|
||||||
corporates: string[];
|
corporates: string[];
|
||||||
|
startDate: Date | null;
|
||||||
|
endDate: Date | null;
|
||||||
}) {
|
}) {
|
||||||
const [assignments, setAssignments] = useState<Assignment[]>([]);
|
const [assignments, setAssignments] = useState<AssignmentWithCorporateId[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isError, setIsError] = useState(false);
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
@@ -18,9 +22,15 @@ export default function useAssignmentsCorporates({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
const urlSearchParams = new URLSearchParams({
|
||||||
|
ids: corporates.join(","),
|
||||||
|
...(startDate ? { startDate: startDate.toISOString() } : {}),
|
||||||
|
...(endDate ? { endDate: endDate.toISOString() } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.get<Assignment[]>(
|
.get<AssignmentWithCorporateId[]>(
|
||||||
`/api/assignments/corporate?ids=${corporates.join(",")}`
|
`/api/assignments/corporate?${urlSearchParams.toString()}`
|
||||||
)
|
)
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
setAssignments(response.data);
|
setAssignments(response.data);
|
||||||
@@ -28,7 +38,7 @@ export default function useAssignmentsCorporates({
|
|||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(getData, [corporates]);
|
useEffect(getData, [corporates, startDate, endDate]);
|
||||||
|
|
||||||
return { assignments, isLoading, isError, reload: getData };
|
return { assignments, isLoading, isError, reload: getData };
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/hooks/useAssignmentRelease.tsx
Normal file
42
src/hooks/useAssignmentRelease.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import React from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import {toast} from "react-toastify";
|
||||||
|
import {BsDoorOpen} from "react-icons/bs";
|
||||||
|
|
||||||
|
export const useAssignmentRelease = (assignmentId: string, reload?: Function) => {
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const archive = () => {
|
||||||
|
// archive assignment
|
||||||
|
setLoading(true);
|
||||||
|
axios
|
||||||
|
.post(`/api/assignments/${assignmentId}/release`)
|
||||||
|
.then((res) => {
|
||||||
|
toast.success("Assignment released!");
|
||||||
|
if (reload) reload();
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error("Failed to release the assignment!");
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderIcon = (downloadClasses: string, loadingClasses: string) => {
|
||||||
|
if (loading) {
|
||||||
|
return <span className={`${loadingClasses} loading loading-infinity w-6`} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="tooltip flex items-center justify-center w-fit h-fit"
|
||||||
|
data-tip="Release assignment"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
archive();
|
||||||
|
}}>
|
||||||
|
<BsDoorOpen className={`${downloadClasses} text-2xl cursor-pointer tooltip`} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return renderIcon;
|
||||||
|
};
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import {Assignment} from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
import axios from "axios";
|
import Axios from "axios";
|
||||||
|
import {setupCache} from "axios-cache-interceptor";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
|
||||||
|
const instance = Axios.create();
|
||||||
|
const axios = setupCache(instance);
|
||||||
|
|
||||||
export default function useAssignments({assigner, assignees, corporate}: {assigner?: string; assignees?: string; corporate?: string}) {
|
export default function useAssignments({assigner, assignees, corporate}: {assigner?: string; assignees?: string; corporate?: string}) {
|
||||||
const [assignments, setAssignments] = useState<Assignment[]>([]);
|
const [assignments, setAssignments] = useState<Assignment[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -13,7 +17,7 @@ export default function useAssignments({assigner, assignees, corporate}: {assign
|
|||||||
.get<Assignment[]>(!corporate ? "/api/assignments" : `/api/assignments/corporate/${corporate}`)
|
.get<Assignment[]>(!corporate ? "/api/assignments" : `/api/assignments/corporate/${corporate}`)
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
if (assigner) {
|
if (assigner) {
|
||||||
setAssignments(response.data.filter((a) => a.assigner === assigner));
|
setAssignments(response.data.filter((a) => a.assigner === assigner || (!a.teachers ? false : a.teachers.includes(assigner))));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
51
src/hooks/useFilterRecordsByUser.tsx
Normal file
51
src/hooks/useFilterRecordsByUser.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const endpoints: Record<string, string> = {
|
||||||
|
stats: "/api/stats",
|
||||||
|
training: "/api/training"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function useFilterRecordsByUser<T extends any[]>(
|
||||||
|
id?: string,
|
||||||
|
shouldNotQuery?: boolean,
|
||||||
|
recordType: string = 'stats'
|
||||||
|
) {
|
||||||
|
type ElementType = T extends (infer U)[] ? U : never;
|
||||||
|
|
||||||
|
const [data, setData] = useState<T>([] as unknown as T);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
|
const endpointURL = endpoints[recordType] || endpoints.stats;
|
||||||
|
// CAUTION: This makes the assumption that the record enpoint has a /user/${id} endpoint
|
||||||
|
const endpoint = !id ? endpointURL: `${endpointURL}/user/${id}`;
|
||||||
|
|
||||||
|
const getData = () => {
|
||||||
|
if (shouldNotQuery) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setIsError(false);
|
||||||
|
|
||||||
|
axios
|
||||||
|
.get<T>(endpoint)
|
||||||
|
.then((response) => {
|
||||||
|
// CAUTION: This makes the assumption ElementType has a "user" field that contains the user id
|
||||||
|
setData(response.data.filter((x: ElementType) => (id ? (x as any).user === id : true)) as T);
|
||||||
|
})
|
||||||
|
.catch(() => setIsError(true))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getData();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [id, shouldNotQuery, recordType, endpoint]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
reload: getData,
|
||||||
|
isLoading,
|
||||||
|
isError
|
||||||
|
};
|
||||||
|
}
|
||||||
22
src/hooks/useGrading.tsx
Normal file
22
src/hooks/useGrading.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import {Grading} from "@/interfaces";
|
||||||
|
import {Code, Group, User} from "@/interfaces/user";
|
||||||
|
import axios from "axios";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
|
||||||
|
export default function useGradingSystem() {
|
||||||
|
const [gradingSystem, setGradingSystem] = useState<Grading>();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
|
const getData = () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
axios
|
||||||
|
.get<Grading>(`/api/grading`)
|
||||||
|
.then((response) => setGradingSystem(response.data))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(getData, []);
|
||||||
|
|
||||||
|
return {gradingSystem, isLoading, isError, reload: getData, mutate: setGradingSystem};
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import {Group, User} from "@/interfaces/user";
|
import {Group, User} from "@/interfaces/user";
|
||||||
import axios from "axios";
|
import Axios from "axios";
|
||||||
|
import {setupCache} from "axios-cache-interceptor";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
|
||||||
|
const instance = Axios.create();
|
||||||
|
const axios = setupCache(instance);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
admin?: string;
|
admin?: string;
|
||||||
userType?: string;
|
userType?: string;
|
||||||
|
|||||||
@@ -1,18 +1,6 @@
|
|||||||
import {useState, useMemo} from "react";
|
import {useState, useMemo} from "react";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
|
import { search } from "@/utils/search";
|
||||||
/*fields example = [
|
|
||||||
['id'],
|
|
||||||
['companyInformation', 'companyInformation', 'name']
|
|
||||||
]*/
|
|
||||||
|
|
||||||
const getFieldValue = (fields: string[], data: any): string => {
|
|
||||||
if (fields.length === 0) return data;
|
|
||||||
const [key, ...otherFields] = fields;
|
|
||||||
|
|
||||||
if (data[key]) return getFieldValue(otherFields, data[key]);
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useListSearch<T>(fields: string[][], rows: T[]) {
|
export function useListSearch<T>(fields: string[][], rows: T[]) {
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
@@ -20,22 +8,11 @@ export function useListSearch<T>(fields: string[][], rows: T[]) {
|
|||||||
const renderSearch = () => <Input label="Search" type="text" name="search" onChange={setText} placeholder="Enter search text" value={text} />;
|
const renderSearch = () => <Input label="Search" type="text" name="search" onChange={setText} placeholder="Enter search text" value={text} />;
|
||||||
|
|
||||||
const updatedRows = useMemo(() => {
|
const updatedRows = useMemo(() => {
|
||||||
const searchText = text.toLowerCase();
|
return search(text, fields, rows);
|
||||||
return rows.filter((row) => {
|
|
||||||
return fields.some((fieldsKeys) => {
|
|
||||||
const value = getFieldValue(fieldsKeys, row);
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value.toLowerCase().includes(searchText);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === "number") {
|
|
||||||
return (value as Number).toString().includes(searchText);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [fields, rows, text]);
|
}, [fields, rows, text]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
text,
|
||||||
rows: updatedRows,
|
rows: updatedRows,
|
||||||
renderSearch,
|
renderSearch,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import {Exam} from "@/interfaces/exam";
|
import {Exam} from "@/interfaces/exam";
|
||||||
import {Permission, PermissionType} from "@/interfaces/permissions";
|
import {Permission, PermissionType} from "@/interfaces/permissions";
|
||||||
import {ExamState} from "@/stores/examStore";
|
import {ExamState} from "@/stores/examStore";
|
||||||
import axios from "axios";
|
import Axios from "axios";
|
||||||
|
import {setupCache} from "axios-cache-interceptor";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
|
||||||
|
const instance = Axios.create();
|
||||||
|
const axios = setupCache(instance);
|
||||||
|
|
||||||
export default function usePermissions(user: string) {
|
export default function usePermissions(user: string) {
|
||||||
const [permissions, setPermissions] = useState<PermissionType[]>([]);
|
const [permissions, setPermissions] = useState<PermissionType[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import {Exam} from "@/interfaces/exam";
|
import {Exam} from "@/interfaces/exam";
|
||||||
import {ExamState} from "@/stores/examStore";
|
import {ExamState} from "@/stores/examStore";
|
||||||
import axios from "axios";
|
import Axios from "axios";
|
||||||
|
import {setupCache} from "axios-cache-interceptor";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
|
||||||
|
const instance = Axios.create();
|
||||||
|
const axios = setupCache(instance);
|
||||||
|
|
||||||
export type Session = ExamState & {user: string; id: string; date: string};
|
export type Session = ExamState & {user: string; id: string; date: string};
|
||||||
|
|
||||||
export default function useSessions(user?: string) {
|
export default function useSessions(user?: string) {
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import {Stat, User} from "@/interfaces/user";
|
|
||||||
import axios from "axios";
|
|
||||||
import {useEffect, useState} from "react";
|
|
||||||
|
|
||||||
export default function useStats(id?: string, shouldNotQuery?: boolean) {
|
|
||||||
const [stats, setStats] = useState<Stat[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [isError, setIsError] = useState(false);
|
|
||||||
|
|
||||||
const getData = () => {
|
|
||||||
if (shouldNotQuery) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
axios
|
|
||||||
.get<Stat[]>(!id ? "/api/stats" : `/api/stats/user/${id}`)
|
|
||||||
.then((response) => setStats(response.data.filter((x) => (id ? x.user === id : true))))
|
|
||||||
.finally(() => setIsLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(getData, [id, shouldNotQuery]);
|
|
||||||
|
|
||||||
return {stats, reload: getData, isLoading, isError};
|
|
||||||
}
|
|
||||||
21
src/hooks/useUserBalance.tsx
Normal file
21
src/hooks/useUserBalance.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import {Code, Group, User} from "@/interfaces/user";
|
||||||
|
import axios from "axios";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
|
||||||
|
export default function useUserBalance() {
|
||||||
|
const [balance, setBalance] = useState<number>(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
|
const getData = () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
axios
|
||||||
|
.get<{balance: number}>(`/api/users/balance`)
|
||||||
|
.then((response) => setBalance(response.data.balance))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(getData, []);
|
||||||
|
|
||||||
|
return {balance, isLoading, isError, reload: getData};
|
||||||
|
}
|
||||||
@@ -1,21 +1,58 @@
|
|||||||
import {User} from "@/interfaces/user";
|
import {Type, User} from "@/interfaces/user";
|
||||||
import axios from "axios";
|
import Axios from "axios";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
import {setupCache} from "axios-cache-interceptor";
|
||||||
|
const instance = Axios.create();
|
||||||
|
const axios = setupCache(instance);
|
||||||
|
|
||||||
export default function useUsers() {
|
export const userHashStudent = { type: "student" } as { type: Type };
|
||||||
|
export const userHashTeacher = { type: "teacher" } as { type: Type };
|
||||||
|
export const userHashCorporate = { type: "corporate" } as { type: Type };
|
||||||
|
|
||||||
|
export default function useUsers(props?: {type?: Type; page?: number; size?: number}) {
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isError, setIsError] = useState(false);
|
const [isError, setIsError] = useState(false);
|
||||||
|
const [latestID, setLatestID] = useState<string>();
|
||||||
|
const [firstID, setFirstID] = useState<string>();
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
|
||||||
const getData = () => {
|
const getData = () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (!!props)
|
||||||
|
Object.keys(props).forEach((key) => {
|
||||||
|
if (!!props[key as keyof typeof props]) params.append(key, props[key as keyof typeof props]!.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!!latestID) params.append("latestID", latestID);
|
||||||
|
if (!!firstID) params.append("firstID", firstID);
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.get<User[]>("/api/users/list", {headers: {page: "register"}})
|
.get<{users: User[]; total: number}>(`/api/users/list?${params.toString()}`, {headers: {page: "register"}})
|
||||||
.then((response) => setUsers(response.data))
|
.then((response) => {
|
||||||
|
setUsers(response.data.users);
|
||||||
|
setTotal(response.data.total);
|
||||||
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(getData, []);
|
const next = () => {
|
||||||
|
setLatestID(users[users.length - 1]?.id);
|
||||||
|
setFirstID(undefined);
|
||||||
|
setPage((prev) => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
return {users, isLoading, isError, reload: getData};
|
const previous = () => {
|
||||||
|
setLatestID(undefined);
|
||||||
|
setFirstID(page > 1 ? users[0]?.id : undefined);
|
||||||
|
setPage((prev) => prev - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
useEffect(getData, [props, latestID, firstID]);
|
||||||
|
|
||||||
|
return {users, total, page, isLoading, isError, reload: getData, next, previous};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,12 @@ interface ExamBase {
|
|||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
difficulty?: Difficulty;
|
difficulty?: Difficulty;
|
||||||
|
owners?: string[];
|
||||||
shuffle?: boolean;
|
shuffle?: boolean;
|
||||||
createdBy?: string; // option as it has been added later
|
createdBy?: string; // option as it has been added later
|
||||||
createdAt?: string; // option as it has been added later
|
createdAt?: string; // option as it has been added later
|
||||||
|
private?: boolean;
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
export interface ReadingExam extends ExamBase {
|
export interface ReadingExam extends ExamBase {
|
||||||
module: "reading";
|
module: "reading";
|
||||||
@@ -38,6 +41,7 @@ export interface LevelExam extends ExamBase {
|
|||||||
export interface LevelPart {
|
export interface LevelPart {
|
||||||
context?: string;
|
context?: string;
|
||||||
intro?: string;
|
intro?: string;
|
||||||
|
category?: string;
|
||||||
exercises: Exercise[];
|
exercises: Exercise[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +71,7 @@ export interface UserSolution {
|
|||||||
};
|
};
|
||||||
exercise: string;
|
exercise: string;
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
shuffleMaps?: ShuffleMap[]
|
shuffleMaps?: ShuffleMap[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WritingExam extends ExamBase {
|
export interface WritingExam extends ExamBase {
|
||||||
@@ -103,7 +107,6 @@ export interface Evaluation {
|
|||||||
misspelled_pairs?: {correction: string | null; misspelled: string}[];
|
misspelled_pairs?: {correction: string | null; misspelled: string}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
type InteractivePerfectAnswerKey = `perfect_answer_${number}`;
|
type InteractivePerfectAnswerKey = `perfect_answer_${number}`;
|
||||||
type InteractiveTranscriptKey = `transcript_${number}`;
|
type InteractiveTranscriptKey = `transcript_${number}`;
|
||||||
type InteractiveFixedTextKey = `fixed_text_${number}`;
|
type InteractiveFixedTextKey = `fixed_text_${number}`;
|
||||||
@@ -112,11 +115,7 @@ type InteractivePerfectAnswerType = { [key in InteractivePerfectAnswerKey]: { an
|
|||||||
type InteractiveTranscriptType = {[key in InteractiveTranscriptKey]?: string};
|
type InteractiveTranscriptType = {[key in InteractiveTranscriptKey]?: string};
|
||||||
type InteractiveFixedTextType = {[key in InteractiveFixedTextKey]?: string};
|
type InteractiveFixedTextType = {[key in InteractiveFixedTextKey]?: string};
|
||||||
|
|
||||||
interface InteractiveSpeakingEvaluation extends Evaluation,
|
interface InteractiveSpeakingEvaluation extends Evaluation, InteractivePerfectAnswerType, InteractiveTranscriptType, InteractiveFixedTextType {}
|
||||||
InteractivePerfectAnswerType,
|
|
||||||
InteractiveTranscriptType,
|
|
||||||
InteractiveFixedTextType { }
|
|
||||||
|
|
||||||
|
|
||||||
interface SpeakingEvaluation extends CommonEvaluation {
|
interface SpeakingEvaluation extends CommonEvaluation {
|
||||||
perfect_answer_1?: string;
|
perfect_answer_1?: string;
|
||||||
@@ -208,7 +207,7 @@ export interface FillBlanksMCOption {
|
|||||||
B: string;
|
B: string;
|
||||||
C: string;
|
C: string;
|
||||||
D: string;
|
D: string;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FillBlanksExercise {
|
export interface FillBlanksExercise {
|
||||||
@@ -303,8 +302,13 @@ export interface MultipleChoiceQuestion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ShuffleMap {
|
export interface ShuffleMap {
|
||||||
id: string;
|
questionID: string;
|
||||||
map: {
|
map: {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Shuffles {
|
||||||
|
exerciseID: string;
|
||||||
|
shuffles: ShuffleMap[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,12 @@
|
|||||||
export type Module = "reading" | "listening" | "writing" | "speaking" | "level";
|
export type Module = "reading" | "listening" | "writing" | "speaking" | "level";
|
||||||
|
|
||||||
|
export interface Step {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Grading {
|
||||||
|
user: string;
|
||||||
|
steps: Step[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,19 +10,30 @@ interface ModuleResult {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AssignmentResult {
|
||||||
|
user: string;
|
||||||
|
type: "academic" | "general";
|
||||||
|
stats: Stat[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Assignment {
|
export interface Assignment {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
assigner: string;
|
assigner: string;
|
||||||
assignees: string[];
|
assignees: string[];
|
||||||
results: {
|
results: AssignmentResult[];
|
||||||
user: string;
|
|
||||||
type: "academic" | "general";
|
|
||||||
stats: Stat[];
|
|
||||||
}[];
|
|
||||||
exams: {id: string; module: Module; assignee: string}[];
|
exams: {id: string; module: Module; assignee: string}[];
|
||||||
instructorGender?: InstructorGender;
|
instructorGender?: InstructorGender;
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
|
teachers?: string[];
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
|
released?: boolean;
|
||||||
|
// unless start is active, the assignment is not visible to the assignees
|
||||||
|
// start date now works as a limit time to start the exam
|
||||||
|
start?: boolean;
|
||||||
|
autoStartDate?: Date;
|
||||||
|
autoStart?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AssignmentWithCorporateId = Assignment & {corporateId: string};
|
||||||
|
|||||||
@@ -2,14 +2,7 @@ import { Module } from ".";
|
|||||||
import {InstructorGender, ShuffleMap} from "./exam";
|
import {InstructorGender, ShuffleMap} from "./exam";
|
||||||
import {PermissionType} from "./permissions";
|
import {PermissionType} from "./permissions";
|
||||||
|
|
||||||
export type User =
|
export type User = StudentUser | TeacherUser | CorporateUser | AgentUser | AdminUser | DeveloperUser | MasterCorporateUser;
|
||||||
| StudentUser
|
|
||||||
| TeacherUser
|
|
||||||
| CorporateUser
|
|
||||||
| AgentUser
|
|
||||||
| AdminUser
|
|
||||||
| DeveloperUser
|
|
||||||
| MasterCorporateUser;
|
|
||||||
export type UserStatus = "active" | "disabled" | "paymentDue";
|
export type UserStatus = "active" | "disabled" | "paymentDue";
|
||||||
|
|
||||||
export interface BasicUser {
|
export interface BasicUser {
|
||||||
@@ -33,6 +26,7 @@ export interface BasicUser {
|
|||||||
|
|
||||||
export interface StudentUser extends BasicUser {
|
export interface StudentUser extends BasicUser {
|
||||||
type: "student";
|
type: "student";
|
||||||
|
studentID?: string;
|
||||||
preferredGender?: InstructorGender;
|
preferredGender?: InstructorGender;
|
||||||
demographicInformation?: DemographicInformation;
|
demographicInformation?: DemographicInformation;
|
||||||
preferredTopics?: string[];
|
preferredTopics?: string[];
|
||||||
@@ -113,15 +107,8 @@ export interface DemographicCorporateInformation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type Gender = "male" | "female" | "other";
|
export type Gender = "male" | "female" | "other";
|
||||||
export type EmploymentStatus =
|
export type EmploymentStatus = "employed" | "student" | "self-employed" | "unemployed" | "retired" | "other";
|
||||||
| "employed"
|
export const EMPLOYMENT_STATUS: {status: EmploymentStatus; label: string}[] = [
|
||||||
| "student"
|
|
||||||
| "self-employed"
|
|
||||||
| "unemployed"
|
|
||||||
| "retired"
|
|
||||||
| "other";
|
|
||||||
export const EMPLOYMENT_STATUS: { status: EmploymentStatus; label: string }[] =
|
|
||||||
[
|
|
||||||
{status: "student", label: "Student"},
|
{status: "student", label: "Student"},
|
||||||
{status: "employed", label: "Employed"},
|
{status: "employed", label: "Employed"},
|
||||||
{status: "unemployed", label: "Unemployed"},
|
{status: "unemployed", label: "Unemployed"},
|
||||||
@@ -176,20 +163,5 @@ export interface Code {
|
|||||||
passport_id?: string;
|
passport_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Type =
|
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent" | "mastercorporate";
|
||||||
| "student"
|
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent", "mastercorporate"];
|
||||||
| "teacher"
|
|
||||||
| "corporate"
|
|
||||||
| "admin"
|
|
||||||
| "developer"
|
|
||||||
| "agent"
|
|
||||||
| "mastercorporate";
|
|
||||||
export const userTypes: Type[] = [
|
|
||||||
"student",
|
|
||||||
"teacher",
|
|
||||||
"corporate",
|
|
||||||
"admin",
|
|
||||||
"developer",
|
|
||||||
"agent",
|
|
||||||
"mastercorporate",
|
|
||||||
];
|
|
||||||
@@ -19,6 +19,7 @@ import {BsFileEarmarkEaselFill, BsQuestionCircleFill} from "react-icons/bs";
|
|||||||
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
||||||
import {PermissionType} from "@/interfaces/permissions";
|
import {PermissionType} from "@/interfaces/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
|
||||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
||||||
|
|
||||||
const USER_TYPE_PERMISSIONS: {
|
const USER_TYPE_PERMISSIONS: {
|
||||||
@@ -34,7 +35,7 @@ const USER_TYPE_PERMISSIONS: {
|
|||||||
},
|
},
|
||||||
agent: {
|
agent: {
|
||||||
perm: "createCodeCountryManager",
|
perm: "createCodeCountryManager",
|
||||||
list: [],
|
list: ["student", "teacher", "corporate", "mastercorporate"],
|
||||||
},
|
},
|
||||||
corporate: {
|
corporate: {
|
||||||
perm: "createCodeCorporate",
|
perm: "createCodeCorporate",
|
||||||
@@ -54,7 +55,14 @@ const USER_TYPE_PERMISSIONS: {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function BatchCodeGenerator({user}: {user: User}) {
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
users: User[];
|
||||||
|
permissions: PermissionType[];
|
||||||
|
onFinish: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BatchCodeGenerator({user, users, permissions, onFinish}: Props) {
|
||||||
const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]);
|
const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||||
@@ -64,9 +72,6 @@ export default function BatchCodeGenerator({user}: {user: User}) {
|
|||||||
const [type, setType] = useState<Type>("student");
|
const [type, setType] = useState<Type>("student");
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
|
||||||
const {users} = useUsers();
|
|
||||||
const {permissions} = usePermissions(user?.id || "");
|
|
||||||
|
|
||||||
const {openFilePicker, filesContent, clear} = useFilePicker({
|
const {openFilePicker, filesContent, clear} = useFilePicker({
|
||||||
accept: ".xlsx",
|
accept: ".xlsx",
|
||||||
multiple: false,
|
multiple: false,
|
||||||
@@ -85,7 +90,7 @@ export default function BatchCodeGenerator({user}: {user: User}) {
|
|||||||
const information = uniqBy(
|
const information = uniqBy(
|
||||||
rows
|
rows
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const [firstName, lastName, country, passport_id, email, ...phone] = row as string[];
|
const [firstName, lastName, country, passport_id, email, phone] = row as string[];
|
||||||
return EMAIL_REGEX.test(email.toString().trim())
|
return EMAIL_REGEX.test(email.toString().trim())
|
||||||
? {
|
? {
|
||||||
email: email.toString().trim().toLowerCase(),
|
email: email.toString().trim().toLowerCase(),
|
||||||
@@ -164,6 +169,8 @@ export default function BatchCodeGenerator({user}: {user: User}) {
|
|||||||
)} codes and they have been notified by e-mail!`,
|
)} codes and they have been notified by e-mail!`,
|
||||||
{toastId: "success"},
|
{toastId: "success"},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onFinish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ import Modal from "@/components/Modal";
|
|||||||
import {BsQuestionCircleFill} from "react-icons/bs";
|
import {BsQuestionCircleFill} from "react-icons/bs";
|
||||||
import {PermissionType} from "@/interfaces/permissions";
|
import {PermissionType} from "@/interfaces/permissions";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {checkAccess} from "@/utils/permissions";
|
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import ReactDatePicker from "react-datepicker";
|
import ReactDatePicker from "react-datepicker";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
import countryCodes from "country-codes-list";
|
||||||
|
|
||||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
||||||
|
|
||||||
type Type = Exclude<UserType, "admin" | "developer" | "agent" | "mastercorporate">;
|
type Type = Exclude<UserType, "admin" | "developer" | "agent" | "mastercorporate">;
|
||||||
@@ -26,7 +29,7 @@ const USER_TYPE_LABELS: {[key in Type]: string} = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const USER_TYPE_PERMISSIONS: {
|
const USER_TYPE_PERMISSIONS: {
|
||||||
[key in Type]: {perm: PermissionType | undefined; list: Type[]};
|
[key in UserType]: {perm: PermissionType | undefined; list: UserType[]};
|
||||||
} = {
|
} = {
|
||||||
student: {
|
student: {
|
||||||
perm: "createCodeStudent",
|
perm: "createCodeStudent",
|
||||||
@@ -36,13 +39,36 @@ const USER_TYPE_PERMISSIONS: {
|
|||||||
perm: "createCodeTeacher",
|
perm: "createCodeTeacher",
|
||||||
list: [],
|
list: [],
|
||||||
},
|
},
|
||||||
|
agent: {
|
||||||
|
perm: "createCodeCountryManager",
|
||||||
|
list: ["student", "teacher", "corporate", "mastercorporate"],
|
||||||
|
},
|
||||||
corporate: {
|
corporate: {
|
||||||
perm: "createCodeCorporate",
|
perm: "createCodeCorporate",
|
||||||
list: ["student", "teacher"],
|
list: ["student", "teacher"],
|
||||||
},
|
},
|
||||||
|
mastercorporate: {
|
||||||
|
perm: undefined,
|
||||||
|
list: ["student", "teacher", "corporate"],
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
perm: "createCodeAdmin",
|
||||||
|
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
|
||||||
|
},
|
||||||
|
developer: {
|
||||||
|
perm: undefined,
|
||||||
|
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function BatchCreateUser({user}: {user: User}) {
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
users: User[];
|
||||||
|
permissions: PermissionType[];
|
||||||
|
onFinish: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BatchCreateUser({user, users, permissions, onFinish}: Props) {
|
||||||
const [infos, setInfos] = useState<
|
const [infos, setInfos] = useState<
|
||||||
{
|
{
|
||||||
email: string;
|
email: string;
|
||||||
@@ -64,8 +90,6 @@ export default function BatchCreateUser({user}: {user: User}) {
|
|||||||
const [type, setType] = useState<Type>("student");
|
const [type, setType] = useState<Type>("student");
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
|
||||||
const {users} = useUsers();
|
|
||||||
|
|
||||||
const {openFilePicker, filesContent, clear} = useFilePicker({
|
const {openFilePicker, filesContent, clear} = useFilePicker({
|
||||||
accept: ".xlsx",
|
accept: ".xlsx",
|
||||||
multiple: false,
|
multiple: false,
|
||||||
@@ -84,7 +108,11 @@ export default function BatchCreateUser({user}: {user: User}) {
|
|||||||
const information = uniqBy(
|
const information = uniqBy(
|
||||||
rows
|
rows
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const [firstName, lastName, country, passport_id, email, phone, group] = row as string[];
|
const [firstName, lastName, studentID, passport_id, email, phone, corporate, group, country] = row as string[];
|
||||||
|
const countryItem =
|
||||||
|
countryCodes.findOne("countryCode" as any, country.toUpperCase()) ||
|
||||||
|
countryCodes.all().find((x) => x.countryNameEn.toLowerCase() === country.toLowerCase());
|
||||||
|
|
||||||
return EMAIL_REGEX.test(email.toString().trim())
|
return EMAIL_REGEX.test(email.toString().trim())
|
||||||
? {
|
? {
|
||||||
email: email.toString().trim().toLowerCase(),
|
email: email.toString().trim().toLowerCase(),
|
||||||
@@ -92,10 +120,12 @@ export default function BatchCreateUser({user}: {user: User}) {
|
|||||||
type: type,
|
type: type,
|
||||||
passport_id: passport_id?.toString().trim() || undefined,
|
passport_id: passport_id?.toString().trim() || undefined,
|
||||||
groupName: group,
|
groupName: group,
|
||||||
|
corporate,
|
||||||
|
studentID,
|
||||||
demographicInformation: {
|
demographicInformation: {
|
||||||
country: country,
|
country: countryItem?.countryCode,
|
||||||
passport_id: passport_id?.toString().trim() || undefined,
|
passport_id: passport_id?.toString().trim() || undefined,
|
||||||
phone,
|
phone: phone.toString(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -131,8 +161,9 @@ export default function BatchCreateUser({user}: {user: User}) {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const newUser of newUsers) await axios.post("/api/make_user", {...newUser, type, expiryDate});
|
await axios.post("/api/batch_users", { users: newUsers.map(user => ({...user, type, expiryDate})) });
|
||||||
toast.success(`Successfully added ${newUsers.length} user(s)!`);
|
toast.success(`Successfully added ${newUsers.length} user(s)!`);
|
||||||
|
onFinish();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Something went wrong, please try again later!");
|
toast.error("Something went wrong, please try again later!");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -153,11 +184,13 @@ export default function BatchCreateUser({user}: {user: User}) {
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="border border-neutral-200 px-2 py-1">First Name</th>
|
<th className="border border-neutral-200 px-2 py-1">First Name</th>
|
||||||
<th className="border border-neutral-200 px-2 py-1">Last Name</th>
|
<th className="border border-neutral-200 px-2 py-1">Last Name</th>
|
||||||
<th className="border border-neutral-200 px-2 py-1">Country</th>
|
<th className="border border-neutral-200 px-2 py-1">Student ID</th>
|
||||||
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
|
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
|
||||||
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
|
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
|
||||||
<th className="border border-neutral-200 px-2 py-1">Phone Number</th>
|
<th className="border border-neutral-200 px-2 py-1">Phone Number</th>
|
||||||
|
{user?.type !== "corporate" && <th className="border border-neutral-200 px-2 py-1">Corporate (e-mail)</th>}
|
||||||
<th className="border border-neutral-200 px-2 py-1">Group Name</th>
|
<th className="border border-neutral-200 px-2 py-1">Group Name</th>
|
||||||
|
<th className="border border-neutral-200 px-2 py-1">Country</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
</table>
|
</table>
|
||||||
@@ -214,7 +247,13 @@ export default function BatchCreateUser({user}: {user: User}) {
|
|||||||
defaultValue="student"
|
defaultValue="student"
|
||||||
onChange={(e) => setType(e.target.value as Type)}
|
onChange={(e) => setType(e.target.value as Type)}
|
||||||
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none">
|
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none">
|
||||||
{Object.keys(USER_TYPE_LABELS).map((type) => (
|
{Object.keys(USER_TYPE_LABELS)
|
||||||
|
.filter((x) => {
|
||||||
|
const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
|
||||||
|
// if (x === "corporate") console.log(list, perm, checkAccess(user, list, permissions, perm));
|
||||||
|
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
||||||
|
})
|
||||||
|
.map((type) => (
|
||||||
<option key={type} value={type}>
|
<option key={type} value={type}>
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {useEffect, useState} from "react";
|
|||||||
import ReactDatePicker from "react-datepicker";
|
import ReactDatePicker from "react-datepicker";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
import {checkAccess} from "@/utils/permissions";
|
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
||||||
import {PermissionType} from "@/interfaces/permissions";
|
import {PermissionType} from "@/interfaces/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ const USER_TYPE_PERMISSIONS: {
|
|||||||
},
|
},
|
||||||
agent: {
|
agent: {
|
||||||
perm: "createCodeCountryManager",
|
perm: "createCodeCountryManager",
|
||||||
list: [],
|
list: ["student", "teacher", "corporate", "mastercorporate"],
|
||||||
},
|
},
|
||||||
corporate: {
|
corporate: {
|
||||||
perm: "createCodeCorporate",
|
perm: "createCodeCorporate",
|
||||||
@@ -48,14 +48,19 @@ const USER_TYPE_PERMISSIONS: {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CodeGenerator({user}: {user: User}) {
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
permissions: PermissionType[];
|
||||||
|
onFinish: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CodeGenerator({user, permissions, onFinish}: Props) {
|
||||||
const [generatedCode, setGeneratedCode] = useState<string>();
|
const [generatedCode, setGeneratedCode] = useState<string>();
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||||
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
|
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
|
||||||
);
|
);
|
||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
const [type, setType] = useState<Type>("student");
|
const [type, setType] = useState<Type>("student");
|
||||||
const {permissions} = usePermissions(user?.id || "");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||||
@@ -103,7 +108,7 @@ export default function CodeGenerator({user}: {user: User}) {
|
|||||||
{Object.keys(USER_TYPE_LABELS)
|
{Object.keys(USER_TYPE_LABELS)
|
||||||
.filter((x) => {
|
.filter((x) => {
|
||||||
const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
|
const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
|
||||||
return checkAccess(user, list, permissions, perm);
|
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
||||||
})
|
})
|
||||||
.map((type) => (
|
.map((type) => (
|
||||||
<option key={type} value={type}>
|
<option key={type} value={type}>
|
||||||
|
|||||||
127
src/pages/(admin)/CorporateGradingSystem.tsx
Normal file
127
src/pages/(admin)/CorporateGradingSystem.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import {Grading, Step} from "@/interfaces";
|
||||||
|
import {User} from "@/interfaces/user";
|
||||||
|
import {CEFR_STEPS, GENERAL_STEPS, IELTS_STEPS, TOFEL_STEPS} from "@/resources/grading";
|
||||||
|
import axios from "axios";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import {BsPlusCircle, BsTrash} from "react-icons/bs";
|
||||||
|
import {toast} from "react-toastify";
|
||||||
|
|
||||||
|
const areStepsOverlapped = (steps: Step[]) => {
|
||||||
|
for (let i = 0; i < steps.length; i++) {
|
||||||
|
if (i === 0) continue;
|
||||||
|
|
||||||
|
const step = steps[i];
|
||||||
|
const previous = steps[i - 1];
|
||||||
|
|
||||||
|
if (previous.max >= step.min) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CorporateGradingSystem({user, defaultSteps, mutate}: {user: User; defaultSteps: Step[]; mutate: (steps: Step[]) => void}) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [steps, setSteps] = useState<Step[]>(defaultSteps || []);
|
||||||
|
|
||||||
|
const saveGradingSystem = () => {
|
||||||
|
if (!steps.every((x) => x.min < x.max)) return toast.error("One of your steps has a minimum threshold inferior to its superior threshold.");
|
||||||
|
if (areStepsOverlapped(steps)) return toast.error("There seems to be an overlap in one of your steps.");
|
||||||
|
if (
|
||||||
|
steps.reduce((acc, curr) => {
|
||||||
|
return acc - (curr.max - curr.min + 1);
|
||||||
|
}, 100) > 0
|
||||||
|
)
|
||||||
|
return toast.error("There seems to be an open interval in your steps.");
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
axios
|
||||||
|
.post("/api/grading", {user: user.id, steps})
|
||||||
|
.then(() => toast.success("Your grading system has been saved!"))
|
||||||
|
.then(() => mutate(steps))
|
||||||
|
.catch(() => toast.error("Something went wrong, please try again later"))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Grading System</label>
|
||||||
|
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Preset Systems</label>
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<Button variant="outline" onClick={() => setSteps(CEFR_STEPS)}>
|
||||||
|
CEFR
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setSteps(GENERAL_STEPS)}>
|
||||||
|
General English
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setSteps(IELTS_STEPS)}>
|
||||||
|
IELTS
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setSteps(TOFEL_STEPS)}>
|
||||||
|
TOFEL iBT
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="grid grid-cols-3 gap-4 w-full" key={step.min}>
|
||||||
|
<Input
|
||||||
|
label="Min. Percentage"
|
||||||
|
value={step.min}
|
||||||
|
type="number"
|
||||||
|
disabled={index === 0 || isLoading}
|
||||||
|
onChange={(e) => setSteps((prev) => prev.map((x, i) => (i === index ? {...x, min: parseInt(e)} : x)))}
|
||||||
|
name="min"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Grade"
|
||||||
|
value={step.label}
|
||||||
|
type="text"
|
||||||
|
disabled={isLoading}
|
||||||
|
onChange={(e) => setSteps((prev) => prev.map((x, i) => (i === index ? {...x, label: e} : x)))}
|
||||||
|
name="min"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Max. Percentage"
|
||||||
|
value={step.max}
|
||||||
|
type="number"
|
||||||
|
disabled={index === steps.length - 1 || isLoading}
|
||||||
|
onChange={(e) => setSteps((prev) => prev.map((x, i) => (i === index ? {...x, max: parseInt(e)} : x)))}
|
||||||
|
name="max"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{index !== 0 && index !== steps.length - 1 && (
|
||||||
|
<button
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pt-9 text-xl group"
|
||||||
|
onClick={() => setSteps((prev) => prev.filter((_, i) => i !== index))}>
|
||||||
|
<div className="w-full h-full flex items-center justify-center group-hover:bg-neutral-200 rounded-full p-3 transition ease-in-out duration-300">
|
||||||
|
<BsTrash />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{index < steps.length - 1 && (
|
||||||
|
<Button
|
||||||
|
className="w-full flex items-center justify-center"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => {
|
||||||
|
const item = {min: steps[index === 0 ? 0 : index - 1].max + 1, max: steps[index + 1].min - 1, label: ""};
|
||||||
|
setSteps((prev) => [...prev.slice(0, index + 1), item, ...prev.slice(index + 1, steps.length)]);
|
||||||
|
}}>
|
||||||
|
<BsPlusCircle />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button onClick={saveGradingSystem} isLoading={isLoading} disabled={isLoading} className="mt-8">
|
||||||
|
Save Grading System
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from "react";
|
import {useMemo, useState} from "react";
|
||||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||||
import useExams from "@/hooks/useExams";
|
import useExams from "@/hooks/useExams";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
@@ -8,18 +8,20 @@ import { Type, 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 {countExercises} from "@/utils/moduleUtils";
|
import {countExercises} from "@/utils/moduleUtils";
|
||||||
import {
|
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||||
createColumnHelper,
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
useReactTable,
|
|
||||||
} from "@tanstack/react-table";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { capitalize } from "lodash";
|
import {capitalize, uniq} from "lodash";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import { BsCheck, BsTrash, BsUpload } from "react-icons/bs";
|
import {BsBan, BsBanFill, BsCheck, BsCircle, BsPencil, BsStop, BsTrash, BsUpload, BsX} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
|
import {useListSearch} from "@/hooks/useListSearch";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import {checkAccess} from "@/utils/permissions";
|
||||||
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
|
||||||
|
const searchFields = [["module"], ["id"], ["createdBy"]];
|
||||||
|
|
||||||
const CLASSES: {[key in Module]: string} = {
|
const CLASSES: {[key in Module]: string} = {
|
||||||
reading: "text-ielts-reading",
|
reading: "text-ielts-reading",
|
||||||
@@ -31,9 +33,40 @@ const CLASSES: { [key in Module]: string } = {
|
|||||||
|
|
||||||
const columnHelper = createColumnHelper<Exam>();
|
const columnHelper = createColumnHelper<Exam>();
|
||||||
|
|
||||||
|
const ExamOwnerSelector = ({options, exam, onSave}: {options: User[]; exam: Exam; onSave: (owners: string[]) => void}) => {
|
||||||
|
const [owners, setOwners] = useState(exam.owners || []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-4 mt-4">
|
||||||
|
{options.map((c) => (
|
||||||
|
<Button
|
||||||
|
variant={owners.includes(c.id) ? "solid" : "outline"}
|
||||||
|
onClick={() => setOwners((prev) => (prev.includes(c.id) ? prev.filter((x) => x !== c.id) : [...prev, c.id]))}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
key={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => onSave(owners)} className="w-full max-w-[200px] self-end">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default function ExamList({user}: {user: User}) {
|
export default function ExamList({user}: {user: User}) {
|
||||||
|
const [selectedExam, setSelectedExam] = useState<Exam>();
|
||||||
|
|
||||||
const {exams, reload} = useExams();
|
const {exams, reload} = useExams();
|
||||||
const {users} = useUsers();
|
const {users} = useUsers();
|
||||||
|
const {groups} = useGroups({admin: user?.id, userType: user?.type});
|
||||||
|
|
||||||
|
const filteredCorporates = useMemo(() => {
|
||||||
|
const participantsAndAdmins = uniq(groups.flatMap((x) => [...x.participants, x.admin])).filter((x) => x !== user?.id);
|
||||||
|
return users.filter((x) => participantsAndAdmins.includes(x.id) && x.type === "corporate");
|
||||||
|
}, [users, groups, user]);
|
||||||
|
|
||||||
const parsedExams = useMemo(() => {
|
const parsedExams = useMemo(() => {
|
||||||
return exams.map((exam) => {
|
return exams.map((exam) => {
|
||||||
@@ -51,6 +84,8 @@ export default function ExamList({ user }: { user: User }) {
|
|||||||
});
|
});
|
||||||
}, [exams, users]);
|
}, [exams, users]);
|
||||||
|
|
||||||
|
const {rows: filteredRows, renderSearch} = useListSearch<Exam>(searchFields, parsedExams);
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
const setExams = useExamStore((state) => state.setExams);
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||||
|
|
||||||
@@ -59,12 +94,9 @@ export default function ExamList({ user }: { user: User }) {
|
|||||||
const loadExam = async (module: Module, examId: string) => {
|
const loadExam = async (module: Module, examId: string) => {
|
||||||
const exam = await getExamById(module, examId.trim());
|
const exam = await getExamById(module, examId.trim());
|
||||||
if (!exam) {
|
if (!exam) {
|
||||||
toast.error(
|
toast.error("Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID", {
|
||||||
"Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID",
|
|
||||||
{
|
|
||||||
toastId: "invalid-exam-id",
|
toastId: "invalid-exam-id",
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -75,13 +107,53 @@ export default function ExamList({ user }: { user: User }) {
|
|||||||
router.push("/exercises");
|
router.push("/exercises");
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteExam = async (exam: Exam) => {
|
const privatizeExam = async (exam: Exam) => {
|
||||||
if (
|
if (!confirm(`Are you sure you want to make this ${capitalize(exam.module)} exam ${exam.private ? "public" : "private"}?`)) return;
|
||||||
!confirm(
|
|
||||||
`Are you sure you want to delete this ${capitalize(exam.module)} exam?`
|
axios
|
||||||
)
|
.patch(`/api/exam/${exam.module}/${exam.id}`, {private: !exam.private})
|
||||||
)
|
.then(() => toast.success(`Updated the "${exam.id}" exam`))
|
||||||
|
.catch((reason) => {
|
||||||
|
if (reason.response.status === 404) {
|
||||||
|
toast.error("Exam not found!");
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reason.response.status === 403) {
|
||||||
|
toast.error("You do not have permission to update this exam!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error("Something went wrong, please try again later.");
|
||||||
|
})
|
||||||
|
.finally(reload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateExam = async (exam: Exam, body: object) => {
|
||||||
|
if (!confirm(`Are you sure you want to update this ${capitalize(exam.module)} exam?`)) return;
|
||||||
|
|
||||||
|
axios
|
||||||
|
.patch(`/api/exam/${exam.module}/${exam.id}`, body)
|
||||||
|
.then(() => toast.success(`Updated the "${exam.id}" exam`))
|
||||||
|
.catch((reason) => {
|
||||||
|
if (reason.response.status === 404) {
|
||||||
|
toast.error("Exam not found!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reason.response.status === 403) {
|
||||||
|
toast.error("You do not have permission to update this exam!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error("Something went wrong, please try again later.");
|
||||||
|
})
|
||||||
|
.finally(reload)
|
||||||
|
.finally(() => setSelectedExam(undefined));
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteExam = async (exam: Exam) => {
|
||||||
|
if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.delete(`/api/exam/${exam.module}/${exam.id}`)
|
.delete(`/api/exam/${exam.module}/${exam.id}`)
|
||||||
@@ -103,11 +175,7 @@ export default function ExamList({ user }: { user: User }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getTotalExercises = (exam: Exam) => {
|
const getTotalExercises = (exam: Exam) => {
|
||||||
if (
|
if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") {
|
||||||
exam.module === "reading" ||
|
|
||||||
exam.module === "listening" ||
|
|
||||||
exam.module === "level"
|
|
||||||
) {
|
|
||||||
return countExercises(exam.parts.flatMap((x) => x.exercises));
|
return countExercises(exam.parts.flatMap((x) => x.exercises));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,11 +189,7 @@ export default function ExamList({ user }: { user: User }) {
|
|||||||
}),
|
}),
|
||||||
columnHelper.accessor("module", {
|
columnHelper.accessor("module", {
|
||||||
header: "Module",
|
header: "Module",
|
||||||
cell: (info) => (
|
cell: (info) => <span className={CLASSES[info.getValue()]}>{capitalize(info.getValue())}</span>,
|
||||||
<span className={CLASSES[info.getValue()]}>
|
|
||||||
{capitalize(info.getValue())}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor((x) => getTotalExercises(x), {
|
columnHelper.accessor((x) => getTotalExercises(x), {
|
||||||
header: "Exercises",
|
header: "Exercises",
|
||||||
@@ -135,6 +199,10 @@ export default function ExamList({ user }: { user: User }) {
|
|||||||
header: "Timer",
|
header: "Timer",
|
||||||
cell: (info) => <>{info.getValue()} minute(s)</>,
|
cell: (info) => <>{info.getValue()} minute(s)</>,
|
||||||
}),
|
}),
|
||||||
|
columnHelper.accessor("private", {
|
||||||
|
header: "Private",
|
||||||
|
cell: (info) => <span className="w-full flex items-center justify-center">{!info.getValue() ? <BsX /> : <BsCheck />}</span>,
|
||||||
|
}),
|
||||||
columnHelper.accessor("createdAt", {
|
columnHelper.accessor("createdAt", {
|
||||||
header: "Created At",
|
header: "Created At",
|
||||||
cell: (info) => {
|
cell: (info) => {
|
||||||
@@ -156,21 +224,29 @@ export default function ExamList({ user }: { user: User }) {
|
|||||||
cell: ({row}: {row: {original: Exam}}) => {
|
cell: ({row}: {row: {original: Exam}}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div
|
{(row.original.owners?.includes(user.id) || checkAccess(user, ["admin", "developer"])) && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
data-tip={row.original.private ? "Set as public" : "Set as private"}
|
||||||
|
onClick={async () => await privatizeExam(row.original)}
|
||||||
|
className="cursor-pointer tooltip">
|
||||||
|
{row.original.private ? <BsCircle /> : <BsBan />}
|
||||||
|
</button>
|
||||||
|
{checkAccess(user, ["admin", "developer", "mastercorporate"]) && (
|
||||||
|
<button data-tip="Edit owners" onClick={() => setSelectedExam(row.original)} className="cursor-pointer tooltip">
|
||||||
|
<BsPencil />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
data-tip="Load exam"
|
data-tip="Load exam"
|
||||||
className="cursor-pointer tooltip"
|
className="cursor-pointer tooltip"
|
||||||
onClick={async () =>
|
onClick={async () => await loadExam(row.original.module, row.original.id)}>
|
||||||
await loadExam(row.original.module, row.original.id)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
</div>
|
</button>
|
||||||
{PERMISSIONS.examManagement.delete.includes(user.type) && (
|
{PERMISSIONS.examManagement.delete.includes(user.type) && (
|
||||||
<div
|
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteExam(row.original)}>
|
||||||
data-tip="Delete"
|
|
||||||
className="cursor-pointer tooltip"
|
|
||||||
onClick={() => deleteExam(row.original)}
|
|
||||||
>
|
|
||||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -181,24 +257,28 @@ export default function ExamList({ user }: { user: User }) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: parsedExams,
|
data: filteredRows,
|
||||||
columns: defaultColumns,
|
columns: defaultColumns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="flex flex-col gap-4 w-full h-full">
|
||||||
|
{renderSearch()}
|
||||||
|
<Modal isOpen={!!selectedExam} title={`Edit Exam Owners - ${selectedExam?.id}`} onClose={() => setSelectedExam(undefined)}>
|
||||||
|
{!!selectedExam ? (
|
||||||
|
<ExamOwnerSelector options={filteredCorporates} exam={selectedExam} onSave={(owners) => updateExam(selectedExam, {owners})} />
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||||
<thead>
|
<thead>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header) => (
|
||||||
<th className="p-4 text-left" key={header.id}>
|
<th className="p-4 text-left" key={header.id}>
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -206,10 +286,7 @@ export default function ExamList({ user }: { user: User }) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="px-2">
|
<tbody className="px-2">
|
||||||
{table.getRowModel().rows.map((row) => (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<tr
|
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
||||||
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
|
|
||||||
key={row.id}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<td className="px-4 py-2" key={cell.id}>
|
<td className="px-4 py-2" key={cell.id}>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
@@ -219,5 +296,6 @@ export default function ExamList({ user }: { user: User }) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {CorporateUser, Group, User} from "@/interfaces/user";
|
|||||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {capitalize, uniq} from "lodash";
|
import {capitalize, uniq} from "lodash";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useMemo, useState} from "react";
|
||||||
import {BsPencil, BsQuestionCircleFill, BsTrash} from "react-icons/bs";
|
import {BsPencil, BsQuestionCircleFill, BsTrash} from "react-icons/bs";
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
@@ -17,6 +17,8 @@ import {getUserCorporate} from "@/utils/groups";
|
|||||||
import {isAgentUser, isCorporateUser, USER_TYPE_LABELS} from "@/resources/user";
|
import {isAgentUser, isCorporateUser, USER_TYPE_LABELS} from "@/resources/user";
|
||||||
import {checkAccess} from "@/utils/permissions";
|
import {checkAccess} from "@/utils/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
import {useListSearch} from "@/hooks/useListSearch";
|
||||||
|
const searchFields = [["name"]];
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<Group>();
|
const columnHelper = createColumnHelper<Group>();
|
||||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
||||||
@@ -63,6 +65,14 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
|||||||
readAs: "ArrayBuffer",
|
readAs: "ArrayBuffer",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const availableUsers = useMemo(() => {
|
||||||
|
if (user.type === "teacher") return users.filter((x) => ["student"].includes(x.type));
|
||||||
|
if (user.type === "corporate") return users.filter((x) => ["teacher", "student"].includes(x.type));
|
||||||
|
if (user.type === "mastercorporate") return users.filter((x) => ["corporate", "teacher", "student"].includes(x.type));
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}, [user, users]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filesContent.length > 0) {
|
if (filesContent.length > 0) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -153,15 +163,7 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
|||||||
value: x,
|
value: x,
|
||||||
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
||||||
}))}
|
}))}
|
||||||
options={users
|
options={availableUsers.map((x) => ({value: x.id, label: `${x.email} - ${x.name}`}))}
|
||||||
.filter((x) =>
|
|
||||||
user.type === "teacher"
|
|
||||||
? x.type === "student"
|
|
||||||
: user.type === "corporate"
|
|
||||||
? x.type === "student" || x.type === "teacher"
|
|
||||||
: x.type === "student" || x.type === "teacher" || x.type === "corporate",
|
|
||||||
)
|
|
||||||
.map((x) => ({value: x.id, label: `${x.email} - ${x.name}`}))}
|
|
||||||
onChange={(value) => setParticipants(value.map((x) => x.value))}
|
onChange={(value) => setParticipants(value.map((x) => x.value))}
|
||||||
isMulti
|
isMulti
|
||||||
isSearchable
|
isSearchable
|
||||||
@@ -171,14 +173,13 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
|||||||
control: (styles) => ({
|
control: (styles) => ({
|
||||||
...styles,
|
...styles,
|
||||||
backgroundColor: "white",
|
backgroundColor: "white",
|
||||||
borderRadius: "999px",
|
|
||||||
padding: "1rem 1.5rem",
|
padding: "1rem 1.5rem",
|
||||||
zIndex: "40",
|
zIndex: "40",
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{user.type !== "teacher" && (
|
{user.type !== "teacher" && (
|
||||||
<Button className="w-full max-w-[300px]" onClick={openFilePicker} isLoading={isLoading} variant="outline">
|
<Button className="w-full max-w-[300px] h-fit" onClick={openFilePicker} isLoading={isLoading} variant="outline">
|
||||||
{filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name}
|
{filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -202,6 +203,7 @@ const filterTypes = ["corporate", "teacher", "mastercorporate"];
|
|||||||
export default function GroupList({user}: {user: User}) {
|
export default function GroupList({user}: {user: User}) {
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [editingGroup, setEditingGroup] = useState<Group>();
|
const [editingGroup, setEditingGroup] = useState<Group>();
|
||||||
|
const [viewingAllParticipants, setViewingAllParticipants] = useState<string>();
|
||||||
|
|
||||||
const {permissions} = usePermissions(user?.id || "");
|
const {permissions} = usePermissions(user?.id || "");
|
||||||
|
|
||||||
@@ -217,6 +219,8 @@ export default function GroupList({user}: {user: User}) {
|
|||||||
adminAdmins: user?.id,
|
adminAdmins: user?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const {rows: filteredRows, renderSearch} = useListSearch<Group>(searchFields, groups);
|
||||||
|
|
||||||
const deleteGroup = (group: Group) => {
|
const deleteGroup = (group: Group) => {
|
||||||
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
|
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
|
||||||
|
|
||||||
@@ -250,11 +254,29 @@ export default function GroupList({user}: {user: User}) {
|
|||||||
}),
|
}),
|
||||||
columnHelper.accessor("participants", {
|
columnHelper.accessor("participants", {
|
||||||
header: "Participants",
|
header: "Participants",
|
||||||
cell: (info) =>
|
cell: (info) => (
|
||||||
info
|
<span>
|
||||||
|
{info
|
||||||
.getValue()
|
.getValue()
|
||||||
|
.slice(0, viewingAllParticipants === info.row.original.id ? undefined : 5)
|
||||||
.map((x) => users.find((y) => y.id === x)?.name)
|
.map((x) => users.find((y) => y.id === x)?.name)
|
||||||
.join(", "),
|
.join(", ")}
|
||||||
|
{info.getValue().length > 5 && viewingAllParticipants !== info.row.original.id && (
|
||||||
|
<button
|
||||||
|
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||||
|
onClick={() => setViewingAllParticipants(info.row.original.id)}>
|
||||||
|
, View More
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{info.getValue().length > 5 && viewingAllParticipants === info.row.original.id && (
|
||||||
|
<button
|
||||||
|
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||||
|
onClick={() => setViewingAllParticipants(undefined)}>
|
||||||
|
, View Less
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
header: "",
|
header: "",
|
||||||
@@ -283,7 +305,7 @@ export default function GroupList({user}: {user: User}) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: groups,
|
data: filteredRows,
|
||||||
columns: defaultColumns,
|
columns: defaultColumns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
});
|
});
|
||||||
@@ -295,7 +317,7 @@ export default function GroupList({user}: {user: User}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full rounded-xl">
|
<div className="h-full w-full rounded-xl flex flex-col gap-4">
|
||||||
<Modal isOpen={isCreating || !!editingGroup} onClose={closeModal} title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}>
|
<Modal isOpen={isCreating || !!editingGroup} onClose={closeModal} title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}>
|
||||||
<CreatePanel
|
<CreatePanel
|
||||||
group={editingGroup}
|
group={editingGroup}
|
||||||
@@ -315,6 +337,7 @@ export default function GroupList({user}: {user: User}) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
{renderSearch()}
|
||||||
<table className="bg-mti-purple-ultralight/40 w-full rounded-xl">
|
<table className="bg-mti-purple-ultralight/40 w-full rounded-xl">
|
||||||
<thead>
|
<thead>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import axios from "axios";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize, reverse} from "lodash";
|
import {capitalize, reverse} from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {Fragment, useEffect, useState} from "react";
|
import {Fragment, useEffect, useState, useMemo} from "react";
|
||||||
import {BsArrowDown, BsArrowDownUp, BsArrowUp, BsCheck, BsCheckCircle, BsEye, BsFillExclamationOctagonFill, BsPerson, BsTrash} from "react-icons/bs";
|
import {BsArrowDown, BsArrowDownUp, BsArrowUp, BsCheck, BsCheckCircle, BsEye, BsFillExclamationOctagonFill, BsPerson, BsTrash} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import {countries, TCountries} from "countries-list";
|
import {countries, TCountries} from "countries-list";
|
||||||
@@ -27,6 +27,7 @@ import {exportListToExcel, UserListRow} from "@/utils/users";
|
|||||||
import {checkAccess} from "@/utils/permissions";
|
import {checkAccess} from "@/utils/permissions";
|
||||||
import {PermissionType} from "@/interfaces/permissions";
|
import {PermissionType} from "@/interfaces/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
import useUserBalance from "@/hooks/useUserBalance";
|
||||||
const columnHelper = createColumnHelper<User>();
|
const columnHelper = createColumnHelper<User>();
|
||||||
const searchFields = [["name"], ["email"], ["corporateInformation", "companyInformation", "name"]];
|
const searchFields = [["name"], ["email"], ["corporateInformation", "companyInformation", "name"]];
|
||||||
|
|
||||||
@@ -45,10 +46,12 @@ const CompanyNameCell = ({users, user, groups}: {user: User; users: User[]; grou
|
|||||||
export default function UserList({
|
export default function UserList({
|
||||||
user,
|
user,
|
||||||
filters = [],
|
filters = [],
|
||||||
|
type,
|
||||||
renderHeader,
|
renderHeader,
|
||||||
}: {
|
}: {
|
||||||
user: User;
|
user: User;
|
||||||
filters?: ((user: User) => boolean)[];
|
filters?: ((user: User) => boolean)[];
|
||||||
|
type?: Type;
|
||||||
renderHeader?: (total: number) => JSX.Element;
|
renderHeader?: (total: number) => JSX.Element;
|
||||||
}) {
|
}) {
|
||||||
const [showDemographicInformation, setShowDemographicInformation] = useState(false);
|
const [showDemographicInformation, setShowDemographicInformation] = useState(false);
|
||||||
@@ -56,8 +59,14 @@ export default function UserList({
|
|||||||
const [displayUsers, setDisplayUsers] = useState<User[]>([]);
|
const [displayUsers, setDisplayUsers] = useState<User[]>([]);
|
||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
|
|
||||||
const {users, reload} = useUsers();
|
const userHash = useMemo(() => ({
|
||||||
|
type,
|
||||||
|
size: 25,
|
||||||
|
}), [type])
|
||||||
|
|
||||||
|
const {users, page, total, reload, next, previous} = useUsers(userHash);
|
||||||
const {permissions} = usePermissions(user?.id || "");
|
const {permissions} = usePermissions(user?.id || "");
|
||||||
|
const {balance} = useUserBalance();
|
||||||
const {groups} = useGroups({
|
const {groups} = useGroups({
|
||||||
admin: user && ["corporate", "teacher", "mastercorporate"].includes(user?.type) ? user.id : undefined,
|
admin: user && ["corporate", "teacher", "mastercorporate"].includes(user?.type) ? user.id : undefined,
|
||||||
userType: user?.type,
|
userType: user?.type,
|
||||||
@@ -78,19 +87,15 @@ export default function UserList({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (user && users) {
|
if (users && users.length > 0) {
|
||||||
const filterUsers = ["corporate", "teacher", "mastercorporate"].includes(user.type)
|
const filteredUsers = filters.reduce((d, f) => d.filter(f), users);
|
||||||
? users.filter((u) => groups.flatMap((g) => g.participants).includes(u.id))
|
|
||||||
: users;
|
|
||||||
|
|
||||||
const filteredUsers = filters.reduce((d, f) => d.filter(f), filterUsers);
|
|
||||||
const sortedUsers = await asyncSorter<User>(filteredUsers, sortFunction);
|
const sortedUsers = await asyncSorter<User>(filteredUsers, sortFunction);
|
||||||
|
|
||||||
setDisplayUsers([...sortedUsers]);
|
setDisplayUsers([...sortedUsers]);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user, users, sorter, groups]);
|
}, [users, sorter]);
|
||||||
|
|
||||||
const deleteAccount = (user: User) => {
|
const deleteAccount = (user: User) => {
|
||||||
if (!confirm(`Are you sure you want to delete ${user.name}'s account?`)) return;
|
if (!confirm(`Are you sure you want to delete ${user.name}'s account?`)) return;
|
||||||
@@ -107,23 +112,6 @@ export default function UserList({
|
|||||||
.finally(reload);
|
.finally(reload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateAccountType = (user: User, type: Type) => {
|
|
||||||
if (!confirm(`Are you sure you want to update ${user.name}'s account from ${capitalize(user.type)} to ${capitalize(type)}?`)) return;
|
|
||||||
|
|
||||||
axios
|
|
||||||
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {
|
|
||||||
...user,
|
|
||||||
type,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success("User type updated successfully!");
|
|
||||||
reload();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Something went wrong!", {toastId: "update-error"});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const verifyAccount = (user: User) => {
|
const verifyAccount = (user: User) => {
|
||||||
axios
|
axios
|
||||||
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {
|
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {
|
||||||
@@ -181,52 +169,6 @@ export default function UserList({
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{checkAccess(user, updateUserPermission.list, permissions, updateUserPermission.perm) && (
|
|
||||||
<Popover className="relative">
|
|
||||||
<Popover.Button>
|
|
||||||
<div data-tip="Change Type" className="cursor-pointer tooltip">
|
|
||||||
<BsPerson className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
|
||||||
</div>
|
|
||||||
</Popover.Button>
|
|
||||||
<Transition
|
|
||||||
as={Fragment}
|
|
||||||
enter="transition ease-out duration-200"
|
|
||||||
enterFrom="opacity-0 translate-y-1"
|
|
||||||
enterTo="opacity-100 translate-y-0"
|
|
||||||
leave="transition ease-in duration-150"
|
|
||||||
leaveFrom="opacity-100 translate-y-0"
|
|
||||||
leaveTo="opacity-0 translate-y-1">
|
|
||||||
<Popover.Panel className="absolute z-10 w-screen right-1/2 translate-x-1/3 max-w-sm">
|
|
||||||
<div className="bg-white p-4 rounded-lg grid grid-cols-2 gap-2 w-full drop-shadow-xl">
|
|
||||||
<Button
|
|
||||||
onClick={() => updateAccountType(row.original, "student")}
|
|
||||||
className="text-sm !py-2 !px-4"
|
|
||||||
disabled={row.original.type === "student" || !PERMISSIONS.generateCode["student"].includes(user.type)}>
|
|
||||||
Student
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => updateAccountType(row.original, "teacher")}
|
|
||||||
className="text-sm !py-2 !px-4"
|
|
||||||
disabled={row.original.type === "teacher" || !PERMISSIONS.generateCode["teacher"].includes(user.type)}>
|
|
||||||
Teacher
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => updateAccountType(row.original, "corporate")}
|
|
||||||
className="text-sm !py-2 !px-4"
|
|
||||||
disabled={row.original.type === "corporate" || !PERMISSIONS.generateCode["corporate"].includes(user.type)}>
|
|
||||||
Corporate
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => updateAccountType(row.original, "admin")}
|
|
||||||
className="text-sm !py-2 !px-4"
|
|
||||||
disabled={row.original.type === "admin" || !PERMISSIONS.generateCode["admin"].includes(user.type)}>
|
|
||||||
Admin
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Popover.Panel>
|
|
||||||
</Transition>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
{!row.original.isVerified && checkAccess(user, updateUserPermission.list, permissions, updateUserPermission.perm) && (
|
{!row.original.isVerified && checkAccess(user, updateUserPermission.list, permissions, updateUserPermission.perm) && (
|
||||||
<div data-tip="Verify User" className="cursor-pointer tooltip" onClick={() => verifyAccount(row.original)}>
|
<div data-tip="Verify User" className="cursor-pointer tooltip" onClick={() => verifyAccount(row.original)}>
|
||||||
<BsCheck className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
<BsCheck className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
@@ -391,6 +333,15 @@ export default function UserList({
|
|||||||
) as any,
|
) as any,
|
||||||
cell: (info) => USER_TYPE_LABELS[info.getValue()],
|
cell: (info) => USER_TYPE_LABELS[info.getValue()],
|
||||||
}),
|
}),
|
||||||
|
columnHelper.accessor("studentID", {
|
||||||
|
header: (
|
||||||
|
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "studentID"))}>
|
||||||
|
<span>Student ID</span>
|
||||||
|
<SorterArrow name="studentID" />
|
||||||
|
</button>
|
||||||
|
) as any,
|
||||||
|
cell: (info) => info.getValue() || "N/A",
|
||||||
|
}),
|
||||||
columnHelper.accessor("corporateInformation.companyInformation.name", {
|
columnHelper.accessor("corporateInformation.companyInformation.name", {
|
||||||
header: (
|
header: (
|
||||||
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "companyName"))}>
|
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "companyName"))}>
|
||||||
@@ -465,6 +416,11 @@ export default function UserList({
|
|||||||
? userTypes.findIndex((t) => a.type === t) - userTypes.findIndex((t) => b.type === t)
|
? userTypes.findIndex((t) => a.type === t) - userTypes.findIndex((t) => b.type === t)
|
||||||
: userTypes.findIndex((t) => b.type === t) - userTypes.findIndex((t) => a.type === t);
|
: userTypes.findIndex((t) => b.type === t) - userTypes.findIndex((t) => a.type === t);
|
||||||
|
|
||||||
|
if (sorter === "studentID" || sorter === reverseString("studentID"))
|
||||||
|
return sorter === "studentID"
|
||||||
|
? (a.type === "student" ? a.studentID || "N/A" : "N/A").localeCompare(b.type === "student" ? b.studentID || "N/A" : "N/A")
|
||||||
|
: (b.type === "student" ? b.studentID || "N/A" : "N/A").localeCompare(a.type === "student" ? a.studentID || "N/A" : "N/A");
|
||||||
|
|
||||||
if (sorter === "verification" || sorter === reverseString("verification"))
|
if (sorter === "verification" || sorter === reverseString("verification"))
|
||||||
return sorter === "verification"
|
return sorter === "verification"
|
||||||
? a.isVerified.toString().localeCompare(b.isVerified.toString())
|
? a.isVerified.toString().localeCompare(b.isVerified.toString())
|
||||||
@@ -583,6 +539,9 @@ export default function UserList({
|
|||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col gap-8">
|
<div className="w-full flex flex-col gap-8">
|
||||||
<UserCard
|
<UserCard
|
||||||
|
maxUserAmount={
|
||||||
|
user.type === "mastercorporate" ? (user.corporateInformation?.companyInformation?.userAmount || 0) - balance : undefined
|
||||||
|
}
|
||||||
loggedInUser={user}
|
loggedInUser={user}
|
||||||
onViewStudents={
|
onViewStudents={
|
||||||
(selectedUser.type === "corporate" || selectedUser.type === "teacher") && studentsFromAdmin.length > 0
|
(selectedUser.type === "corporate" || selectedUser.type === "teacher") && studentsFromAdmin.length > 0
|
||||||
@@ -648,7 +607,7 @@ export default function UserList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{renderHeader && renderHeader(displayUsers.length)}
|
{renderHeader && renderHeader(total)}
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}>
|
<Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}>
|
||||||
{selectedUser && renderUserCard(selectedUser)}
|
{selectedUser && renderUserCard(selectedUser)}
|
||||||
@@ -660,6 +619,14 @@ export default function UserList({
|
|||||||
Download List
|
Download List
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-full flex gap-2 justify-between">
|
||||||
|
<Button className="w-full max-w-[200px]" disabled={page === 0} onClick={previous}>
|
||||||
|
Previous Page
|
||||||
|
</Button>
|
||||||
|
<Button className="w-full max-w-[200px]" disabled={page * 25 >= total} onClick={next}>
|
||||||
|
Next Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||||
<thead>
|
<thead>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ import PackageList from "./PackageList";
|
|||||||
import UserList from "./UserList";
|
import UserList from "./UserList";
|
||||||
import {checkAccess} from "@/utils/permissions";
|
import {checkAccess} from "@/utils/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
import {PermissionType} from "@/interfaces/permissions";
|
||||||
|
|
||||||
export default function Lists({user}: {user: User}) {
|
interface Props {
|
||||||
const {permissions} = usePermissions(user?.id || "");
|
user: User;
|
||||||
|
users: User[];
|
||||||
|
permissions: PermissionType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Lists({user, users, permissions}: Props) {
|
||||||
return (
|
return (
|
||||||
<TabGroup>
|
<TabGroup>
|
||||||
<TabList className="flex space-x-1 rounded-xl bg-mti-purple-ultralight/40 p-1">
|
<TabList className="flex space-x-1 rounded-xl bg-mti-purple-ultralight/40 p-1">
|
||||||
|
|||||||
284
src/pages/(admin)/UserCreator.tsx
Normal file
284
src/pages/(admin)/UserCreator.tsx
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||||
|
import {CorporateUser, TeacherUser, Type, User} from "@/interfaces/user";
|
||||||
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
|
import axios from "axios";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {capitalize, uniqBy} from "lodash";
|
||||||
|
import moment from "moment";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import ReactDatePicker from "react-datepicker";
|
||||||
|
import {toast} from "react-toastify";
|
||||||
|
import ShortUniqueId from "short-unique-id";
|
||||||
|
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
||||||
|
import {PermissionType} from "@/interfaces/permissions";
|
||||||
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import CountrySelect from "@/components/Low/CountrySelect";
|
||||||
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import {getUserName} from "@/utils/users";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
|
||||||
|
const USER_TYPE_PERMISSIONS: {
|
||||||
|
[key in Type]: {perm: PermissionType | undefined; list: Type[]};
|
||||||
|
} = {
|
||||||
|
student: {
|
||||||
|
perm: "createCodeStudent",
|
||||||
|
list: [],
|
||||||
|
},
|
||||||
|
teacher: {
|
||||||
|
perm: "createCodeTeacher",
|
||||||
|
list: [],
|
||||||
|
},
|
||||||
|
agent: {
|
||||||
|
perm: "createCodeCountryManager",
|
||||||
|
list: ["student", "teacher", "corporate", "mastercorporate"],
|
||||||
|
},
|
||||||
|
corporate: {
|
||||||
|
perm: "createCodeCorporate",
|
||||||
|
list: ["student", "teacher"],
|
||||||
|
},
|
||||||
|
mastercorporate: {
|
||||||
|
perm: undefined,
|
||||||
|
list: ["student", "teacher", "corporate"],
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
perm: "createCodeAdmin",
|
||||||
|
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
|
||||||
|
},
|
||||||
|
developer: {
|
||||||
|
perm: undefined,
|
||||||
|
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
users: User[];
|
||||||
|
permissions: PermissionType[];
|
||||||
|
onFinish: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserCreator({user, users, permissions, onFinish}: Props) {
|
||||||
|
const [name, setName] = useState<string>();
|
||||||
|
const [email, setEmail] = useState<string>();
|
||||||
|
const [phone, setPhone] = useState<string>();
|
||||||
|
const [passportID, setPassportID] = useState<string>();
|
||||||
|
const [studentID, setStudentID] = useState<string>();
|
||||||
|
const [country, setCountry] = useState(user?.demographicInformation?.country);
|
||||||
|
const [group, setGroup] = useState<string | null>();
|
||||||
|
const [availableCorporates, setAvailableCorporates] = useState<User[]>([]);
|
||||||
|
const [selectedCorporate, setSelectedCorporate] = useState<string | null>();
|
||||||
|
const [password, setPassword] = useState<string>();
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState<string>();
|
||||||
|
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||||
|
user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null,
|
||||||
|
);
|
||||||
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [type, setType] = useState<Type>("student");
|
||||||
|
const [position, setPosition] = useState<string>();
|
||||||
|
|
||||||
|
const {groups} = useGroups({admin: ["developer", "admin"].includes(user?.type) ? undefined : user?.id, userType: user?.type});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||||
|
}, [isExpiryDateEnabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAvailableCorporates(
|
||||||
|
uniqBy(
|
||||||
|
users.filter((u) => u.type === "corporate" && groups.flatMap((g) => g.participants).includes(u.id)),
|
||||||
|
"id",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, [users, groups]);
|
||||||
|
|
||||||
|
const createUser = () => {
|
||||||
|
if (!name || name.trim().length === 0) return toast.error("Please enter a valid name!");
|
||||||
|
if (!email || email.trim().length === 0) return toast.error("Please enter a valid e-mail address!");
|
||||||
|
if (users.map((x) => x.email).includes(email.trim())) return toast.error("That e-mail is already in use!");
|
||||||
|
if (!password || password.trim().length < 6) return toast.error("Please enter a valid password!");
|
||||||
|
if (password !== confirmPassword) return toast.error("The passwords do not match!");
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
groupID: group,
|
||||||
|
corporate: selectedCorporate || user.id,
|
||||||
|
type,
|
||||||
|
studentID: type === "student" ? studentID : undefined,
|
||||||
|
expiryDate,
|
||||||
|
demographicInformation: {
|
||||||
|
passport_id: type === "student" ? passportID : undefined,
|
||||||
|
phone,
|
||||||
|
country,
|
||||||
|
position,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
axios
|
||||||
|
.post("/api/make_user", body)
|
||||||
|
.then(() => {
|
||||||
|
toast.success("That user has been created!");
|
||||||
|
onFinish();
|
||||||
|
|
||||||
|
setName("");
|
||||||
|
setEmail("");
|
||||||
|
setPhone("");
|
||||||
|
setPassportID("");
|
||||||
|
setStudentID("");
|
||||||
|
setCountry(user?.demographicInformation?.country);
|
||||||
|
setGroup(null);
|
||||||
|
setSelectedCorporate(null);
|
||||||
|
setExpiryDate(user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null);
|
||||||
|
setIsExpiryDateEnabled(true);
|
||||||
|
setType("student");
|
||||||
|
setPosition(undefined);
|
||||||
|
})
|
||||||
|
.catch(() => toast.error("Something went wrong! Please try again later!"))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input required label="Name" value={name} onChange={setName} type="text" name="name" placeholder="Name" />
|
||||||
|
<Input label="E-mail" required value={email} onChange={setEmail} type="email" name="email" placeholder="E-mail" />
|
||||||
|
|
||||||
|
<Input type="password" name="password" label="Password" value={password} onChange={setPassword} placeholder="Password" required />
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="confirmPassword"
|
||||||
|
label="Confirm Password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={setConfirmPassword}
|
||||||
|
placeholder="ConfirmPassword"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
||||||
|
<CountrySelect value={country} onChange={setCountry} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input type="tel" name="phone" label="Phone number" value={phone} onChange={setPhone} placeholder="Phone number" required />
|
||||||
|
|
||||||
|
{type === "student" && (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="passport_id"
|
||||||
|
label="Passport/National ID"
|
||||||
|
onChange={setPassportID}
|
||||||
|
value={passportID}
|
||||||
|
placeholder="National ID or Passport number"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input type="text" name="studentID" label="Student ID" onChange={setStudentID} value={studentID} placeholder="Student ID" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{["student", "teacher"].includes(type) && !["corporate", "teacher"].includes(user?.type) && (
|
||||||
|
<div className={clsx("flex flex-col gap-4")}>
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Corporate</label>
|
||||||
|
<Select
|
||||||
|
options={availableCorporates.map((u) => ({value: u.id, label: getUserName(u)}))}
|
||||||
|
isClearable
|
||||||
|
onChange={(e) => setSelectedCorporate(e?.value || undefined)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{["corporate", "mastercorporate"].includes(type) && (
|
||||||
|
<Input type="text" name="department" label="Department" onChange={setPosition} value={position} placeholder="Department" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!(type === "corporate" && user.type === "corporate") && (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"flex flex-col gap-4",
|
||||||
|
(!["student", "teacher"].includes(type) || ["corporate", "teacher"].includes(user?.type)) &&
|
||||||
|
!["corporate", "mastercorporate"].includes(type) &&
|
||||||
|
"col-span-2",
|
||||||
|
)}>
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Group</label>
|
||||||
|
<Select
|
||||||
|
options={groups
|
||||||
|
.filter((x) => (!selectedCorporate ? true : x.admin === selectedCorporate))
|
||||||
|
.map((g) => ({value: g.id, label: g.name}))}
|
||||||
|
onChange={(e) => setGroup(e?.value || undefined)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"flex flex-col gap-4",
|
||||||
|
!checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && "col-span-2",
|
||||||
|
)}>
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Type</label>
|
||||||
|
{user && (
|
||||||
|
<select
|
||||||
|
defaultValue="student"
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value as Type)}
|
||||||
|
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
||||||
|
{Object.keys(USER_TYPE_LABELS)
|
||||||
|
.filter((x) => {
|
||||||
|
const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
|
||||||
|
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
||||||
|
})
|
||||||
|
.map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
||||||
|
<>
|
||||||
|
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||||
|
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
||||||
|
<Checkbox
|
||||||
|
isChecked={isExpiryDateEnabled}
|
||||||
|
onChange={setIsExpiryDateEnabled}
|
||||||
|
disabled={!!user?.subscriptionExpirationDate}>
|
||||||
|
Enabled
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
{isExpiryDateEnabled && (
|
||||||
|
<ReactDatePicker
|
||||||
|
className={clsx(
|
||||||
|
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||||
|
"hover:border-mti-purple tooltip",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
)}
|
||||||
|
filterDate={(date) =>
|
||||||
|
moment(date).isAfter(new Date()) &&
|
||||||
|
(user?.subscriptionExpirationDate ? moment(date).isBefore(user?.subscriptionExpirationDate) : true)
|
||||||
|
}
|
||||||
|
dateFormat="dd/MM/yyyy"
|
||||||
|
selected={expiryDate}
|
||||||
|
onChange={(date) => setExpiryDate(date)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={createUser} isLoading={isLoading} disabled={(isExpiryDateEnabled ? !expiryDate : false) || isLoading}>
|
||||||
|
Create User
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import Speaking from "@/exams/Speaking";
|
|||||||
import Writing from "@/exams/Writing";
|
import Writing from "@/exams/Writing";
|
||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
import {Exam, LevelExam, UserSolution, Variant} from "@/interfaces/exam";
|
import {Exam, LevelExam, UserSolution, Variant} from "@/interfaces/exam";
|
||||||
import {Stat} from "@/interfaces/user";
|
import {Stat, User} from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {evaluateSpeakingAnswer, evaluateWritingAnswer} from "@/utils/evaluation";
|
import {evaluateSpeakingAnswer, evaluateWritingAnswer} from "@/utils/evaluation";
|
||||||
import {defaultExamUserSolutions, getExam} from "@/utils/exams";
|
import {defaultExamUserSolutions, getExam} from "@/utils/exams";
|
||||||
@@ -24,12 +24,14 @@ import {v4 as uuidv4} from "uuid";
|
|||||||
import useSessions from "@/hooks/useSessions";
|
import useSessions from "@/hooks/useSessions";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import useGradingSystem from "@/hooks/useGrading";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
page: "exams" | "exercises";
|
page: "exams" | "exercises";
|
||||||
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExamPage({page}: Props) {
|
export default function ExamPage({page, user}: Props) {
|
||||||
const [variant, setVariant] = useState<Variant>("full");
|
const [variant, setVariant] = useState<Variant>("full");
|
||||||
const [avoidRepeated, setAvoidRepeated] = useState(false);
|
const [avoidRepeated, setAvoidRepeated] = useState(false);
|
||||||
const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
|
const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
|
||||||
@@ -56,8 +58,8 @@ export default function ExamPage({page}: Props) {
|
|||||||
const {selectedModules, setSelectedModules} = useExamStore((state) => state);
|
const {selectedModules, setSelectedModules} = useExamStore((state) => state);
|
||||||
const {inactivity, setInactivity} = useExamStore((state) => state);
|
const {inactivity, setInactivity} = useExamStore((state) => state);
|
||||||
const {bgColor, setBgColor} = useExamStore((state) => state);
|
const {bgColor, setBgColor} = useExamStore((state) => state);
|
||||||
|
const setShuffleMaps = useExamStore((state) => state.setShuffles);
|
||||||
|
|
||||||
const {user} = useUser({redirectTo: "/login"});
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -204,9 +206,7 @@ export default function ExamPage({page}: Props) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showSolutions) {
|
if (showSolutions) setModuleIndex(-1);
|
||||||
setModuleIndex(-1);
|
|
||||||
}
|
|
||||||
}, [setModuleIndex, showSolutions]);
|
}, [setModuleIndex, showSolutions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -214,7 +214,7 @@ export default function ExamPage({page}: Props) {
|
|||||||
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
|
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
|
||||||
const nextExam = exams[moduleIndex];
|
const nextExam = exams[moduleIndex];
|
||||||
|
|
||||||
if (partIndex === -1 && nextExam.module !== "listening") setPartIndex(0);
|
if (partIndex === -1 && nextExam?.module !== "listening") setPartIndex(0);
|
||||||
if (exerciseIndex === -1 && !["reading", "listening"].includes(nextExam?.module)) setExerciseIndex(0);
|
if (exerciseIndex === -1 && !["reading", "listening"].includes(nextExam?.module)) setExerciseIndex(0);
|
||||||
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
|
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
|
||||||
}
|
}
|
||||||
@@ -276,18 +276,13 @@ export default function ExamPage({page}: Props) {
|
|||||||
}, [statsAwaitingEvaluation]);
|
}, [statsAwaitingEvaluation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (statsAwaitingEvaluation.length > 0) {
|
if (statsAwaitingEvaluation.length > 0) checkIfStatsHaveBeenEvaluated(statsAwaitingEvaluation);
|
||||||
checkIfStatsHaveBeenEvaluated(statsAwaitingEvaluation);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [statsAwaitingEvaluation]);
|
}, [statsAwaitingEvaluation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (exam && exam.module === "level" && !showSolutions) setBgColor("bg-ielts-level-light");
|
||||||
if(exam && exam.module === "level" && exam.parts[0].intro && !showSolutions) {
|
}, [exam, showSolutions, setBgColor]);
|
||||||
setBgColor("bg-ielts-level-light");
|
|
||||||
}
|
|
||||||
}, [exam, showSolutions, setBgColor])
|
|
||||||
|
|
||||||
const checkIfStatsHaveBeenEvaluated = (ids: string[]) => {
|
const checkIfStatsHaveBeenEvaluated = (ids: string[]) => {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
@@ -464,6 +459,7 @@ export default function ExamPage({page}: Props) {
|
|||||||
user={user!}
|
user={user!}
|
||||||
modules={selectedModules}
|
modules={selectedModules}
|
||||||
solutions={userSolutions}
|
solutions={userSolutions}
|
||||||
|
assignment={assignment}
|
||||||
information={{
|
information={{
|
||||||
timeSpent,
|
timeSpent,
|
||||||
inactivity: totalInactivity,
|
inactivity: totalInactivity,
|
||||||
@@ -471,7 +467,7 @@ export default function ExamPage({page}: Props) {
|
|||||||
onViewResults={(index?: number) => {
|
onViewResults={(index?: number) => {
|
||||||
if (exams[0].module === "level") {
|
if (exams[0].module === "level") {
|
||||||
const levelExam = exams[0] as LevelExam;
|
const levelExam = exams[0] as LevelExam;
|
||||||
const allExercises = levelExam.parts.flatMap(part => part.exercises);
|
const allExercises = levelExam.parts.flatMap((part) => part.exercises);
|
||||||
const exerciseOrderMap = new Map(allExercises.map((ex, index) => [ex.id, index]));
|
const exerciseOrderMap = new Map(allExercises.map((ex, index) => [ex.id, index]));
|
||||||
const orderedSolutions = userSolutions.slice().sort((a, b) => {
|
const orderedSolutions = userSolutions.slice().sort((a, b) => {
|
||||||
const indexA = exerciseOrderMap.get(a.exercise) ?? Infinity;
|
const indexA = exerciseOrderMap.get(a.exercise) ?? Infinity;
|
||||||
@@ -482,6 +478,7 @@ export default function ExamPage({page}: Props) {
|
|||||||
} else {
|
} else {
|
||||||
setUserSolutions(userSolutions);
|
setUserSolutions(userSolutions);
|
||||||
}
|
}
|
||||||
|
setShuffleMaps([]);
|
||||||
setShowSolutions(true);
|
setShowSolutions(true);
|
||||||
setModuleIndex(index || 0);
|
setModuleIndex(index || 0);
|
||||||
setExerciseIndex(["reading", "listening"].includes(exams[0].module) ? -1 : 0);
|
setExerciseIndex(["reading", "listening"].includes(exams[0].module) ? -1 : 0);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import FillBlanksEdit from "@/components/Generation/fill.blanks.edit";
|
import FillBlanksEdit from "@/components/Generation/fill.blanks.edit";
|
||||||
import MultipleChoiceEdit from "@/components/Generation/multiple.choice.edit";
|
import MultipleChoiceEdit from "@/components/Generation/multiple.choice.edit";
|
||||||
import WriteBlankEdits from "@/components/Generation/write.blanks.edit";
|
import WriteBlankEdits from "@/components/Generation/write.blanks.edit";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +28,12 @@ import reactStringReplace from "react-string-replace";
|
|||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { v4 } from "uuid";
|
import { v4 } from "uuid";
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
[key: string]: any;
|
||||||
|
value: string | null;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||||
const TYPES: { [key: string]: string } = {
|
const TYPES: { [key: string]: string } = {
|
||||||
multiple_choice_4: "Multiple Choice",
|
multiple_choice_4: "Multiple Choice",
|
||||||
@@ -34,6 +41,7 @@ const TYPES: {[key: string]: string} = {
|
|||||||
multiple_choice_underlined: "Multiple Choice - Underlined",
|
multiple_choice_underlined: "Multiple Choice - Underlined",
|
||||||
blank_space_text: "Blank Space",
|
blank_space_text: "Blank Space",
|
||||||
reading_passage_utas: "Reading Passage",
|
reading_passage_utas: "Reading Passage",
|
||||||
|
fill_blanks_mc: "Multiple Choice - Fill Blanks",
|
||||||
};
|
};
|
||||||
|
|
||||||
type LevelSection = { type: string; quantity: number; topic?: string; part?: LevelPart };
|
type LevelSection = { type: string; quantity: number; topic?: string; part?: LevelPart };
|
||||||
@@ -107,9 +115,16 @@ const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (section: LevelSection) => void}) => {
|
const TaskTab = ({ section, label, index, setSection }: { section: LevelSection; label: string, index: number, setSection: (section: LevelSection) => void }) => {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const [category, setCategory] = useState<string>("");
|
||||||
|
const [description, setDescription] = useState<string>("");
|
||||||
|
const [customDescription, setCustomDescription] = useState<string>("");
|
||||||
|
const [previousOption, setPreviousOption] = useState<Option>({ value: "None", label: "None" });
|
||||||
|
const [descriptionOption, setDescriptionOption] = useState<Option>({ value: "None", label: "None" });
|
||||||
|
const [updateIntro, setUpdateIntro] = useState<boolean>(false);
|
||||||
|
|
||||||
const onUpdate = (question: MultipleChoiceQuestion) => {
|
const onUpdate = (question: MultipleChoiceQuestion) => {
|
||||||
if (!section) return;
|
if (!section) return;
|
||||||
|
|
||||||
@@ -123,6 +138,66 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
|
|||||||
setSection(updatedExam as any);
|
setSection(updatedExam as any);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultPresets: any = {
|
||||||
|
multiple_choice_4: "Welcome to {part} of the {label}. In this section, you'll be asked to select the correct word or group of words that best completes each sentence.\n\nFor each question, carefully read the sentence and click on the option (A, B, C, or D) that you believe is correct. After making your selection, you can proceed to the next question by clicking \"Next\". If you need to review or change your previous answers, you can go back at any time by clicking \"Back\".",
|
||||||
|
multiple_choice_blank_space: undefined,
|
||||||
|
multiple_choice_underlined: "Welcome to {part} of the {label}. In this section, you'll be asked to identify the underlined word or group of words that is not correct in each sentence.\n\nFor each question, carefully review the sentence and click on the option (A, B, C, or D) that you believe contains the incorrect word or group of words. After making your selection, you can proceed to the next question by clicking \"Next\". If needed, you can go back to previous questions by clicking \"Back\".",
|
||||||
|
blank_space_text: undefined,
|
||||||
|
reading_passage_utas: "Welcome to {part} of the {label}. In this section, you will read a text and answer the questions that follow.\n\nCarefully read the provided text, then select the correct answer (A, B, C, or D) for each question. After making your selection, you can proceed to the next question by clicking \"Next\". If you need to review or change your answers, you can go back at any time by clicking \"Back\".",
|
||||||
|
fill_blanks_mc: "Welcome to {part} of the {label}. In this section, you will read a text and choose the correct word to fill in each blank space.\n\nFor each question, carefully read the text and click on the option that you believe best fits the context."
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDefaultPreset = () => {
|
||||||
|
return defaultPresets[section.type] ? defaultPresets[section.type].replace('{part}', `Part ${index + 1}`).replace('{label}', label) :
|
||||||
|
"No default preset is yet available for this type of exercise."
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (descriptionOption.value === "Default" && section?.type) {
|
||||||
|
setDescription(getDefaultPreset())
|
||||||
|
}
|
||||||
|
if (descriptionOption.value === "Custom" && customDescription !== "") {
|
||||||
|
setDescription(customDescription);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [descriptionOption, section?.type, label])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (section?.type) {
|
||||||
|
const defaultPreset = getDefaultPreset();
|
||||||
|
if (descriptionOption.value === "Default" && previousOption.value === "Default" && description !== defaultPreset) {
|
||||||
|
setDescriptionOption({ value: "Custom", label: "Custom" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [descriptionOption, description, label])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPreviousOption(descriptionOption);
|
||||||
|
}, [descriptionOption])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (section?.part && ((descriptionOption.value === "Custom" || descriptionOption.value === "Default") && !section.part.intro)) {
|
||||||
|
setUpdateIntro(true);
|
||||||
|
}
|
||||||
|
}, [section?.part, descriptionOption, category])
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (updateIntro && section.part) {
|
||||||
|
setSection({
|
||||||
|
...section,
|
||||||
|
part: {
|
||||||
|
...section.part!,
|
||||||
|
intro: descriptionOption.value === "Default" ? getDefaultPreset() : (descriptionOption.value === "Custom" ? customDescription : undefined),
|
||||||
|
category: category === "" ? undefined : category
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setUpdateIntro(false);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [updateIntro, section?.part])
|
||||||
|
|
||||||
const renderExercise = (exercise: Exercise) => {
|
const renderExercise = (exercise: Exercise) => {
|
||||||
if (exercise.type === "multipleChoice")
|
if (exercise.type === "multipleChoice")
|
||||||
return (
|
return (
|
||||||
@@ -137,7 +212,12 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
|
|||||||
updateExercise={(data: any) =>
|
updateExercise={(data: any) =>
|
||||||
setSection({
|
setSection({
|
||||||
...section,
|
...section,
|
||||||
part: {...section.part!, exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x))},
|
part: {
|
||||||
|
...section.part!,
|
||||||
|
exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? { ...x, ...data } : x)),
|
||||||
|
intro: descriptionOption.value === "Default" ? getDefaultPreset() : (descriptionOption.value === "Custom" ? customDescription : undefined),
|
||||||
|
category: category === "" ? undefined : category
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -157,7 +237,12 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
|
|||||||
updateExercise={(data: any) =>
|
updateExercise={(data: any) =>
|
||||||
setSection({
|
setSection({
|
||||||
...section,
|
...section,
|
||||||
part: {...section.part!, exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x))},
|
part: {
|
||||||
|
...section.part!,
|
||||||
|
exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? { ...x, ...data } : x)),
|
||||||
|
intro: descriptionOption.value === "Default" ? getDefaultPreset() : (descriptionOption.value === "Custom" ? customDescription : undefined),
|
||||||
|
category: category === "" ? undefined : category
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -177,7 +262,12 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
|
|||||||
updateExercise={(data: any) =>
|
updateExercise={(data: any) =>
|
||||||
setSection({
|
setSection({
|
||||||
...section,
|
...section,
|
||||||
part: {...section.part!, exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x))},
|
part: {
|
||||||
|
...section.part!,
|
||||||
|
exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? { ...x, ...data } : x)),
|
||||||
|
intro: descriptionOption.value === "Default" ? getDefaultPreset() : (descriptionOption.value === "Custom" ? customDescription : undefined),
|
||||||
|
category: category === "" ? undefined : category
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -187,7 +277,38 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Panel className="w-full bg-ielts-level/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-level/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-row w-full gap-4">
|
||||||
|
<div className="flex flex-col gap-3 w-1/2">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Description</label>
|
||||||
|
<Select
|
||||||
|
options={["None", "Default", "Custom"].map((descriptionOption) => ({ value: descriptionOption, label: descriptionOption }))}
|
||||||
|
onChange={(o) => setDescriptionOption({ value: o!.value, label: o!.label })}
|
||||||
|
value={descriptionOption}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 w-1/2">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Category</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Category"
|
||||||
|
name="category"
|
||||||
|
onChange={(e) => setCategory(e)}
|
||||||
|
roundness="full"
|
||||||
|
defaultValue={category}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{descriptionOption.value !== "None" && (
|
||||||
|
<Input
|
||||||
|
type="textarea"
|
||||||
|
placeholder="Part Description"
|
||||||
|
name="category"
|
||||||
|
onChange={(e) => { setDescription(e); setCustomDescription(e); }}
|
||||||
|
roundness="full"
|
||||||
|
value={descriptionOption.value === "Default" ? description : customDescription}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex gap-4 w-full">
|
<div className="flex gap-4 w-full">
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Exercise Type</label>
|
<label className="font-normal text-base text-mti-gray-dim">Exercise Type</label>
|
||||||
@@ -198,7 +319,7 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Number of Questions</label>
|
<label className="font-normal text-base text-mti-gray-dim">{section?.type && section.type === "fill_blanks_mc" ? "Number of Words" : "Number of Questions"}</label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
name="Number of Questions"
|
name="Number of Questions"
|
||||||
@@ -207,7 +328,7 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{section?.type === "reading_passage_utas" && (
|
{section?.type === "reading_passage_utas" || section?.type === "fill_blanks_mc" && (
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Topic</label>
|
<label className="font-normal text-base text-mti-gray-dim">Topic</label>
|
||||||
<Input type="text" name="Topic" onChange={(v) => setSection({ ...section, topic: v })} value={section?.topic} />
|
<Input type="text" name="Topic" onChange={(v) => setSection({ ...section, topic: v })} value={section?.topic} />
|
||||||
@@ -242,6 +363,8 @@ const LevelGeneration = ({ id } : Props) => {
|
|||||||
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||||
const [numberOfParts, setNumberOfParts] = useState(1);
|
const [numberOfParts, setNumberOfParts] = useState(1);
|
||||||
const [parts, setParts] = useState<LevelSection[]>([{ quantity: 10, type: "multiple_choice_4" }]);
|
const [parts, setParts] = useState<LevelSection[]>([{ quantity: 10, type: "multiple_choice_4" }]);
|
||||||
|
const [isPrivate, setPrivate] = useState<boolean>(false);
|
||||||
|
const [label, setLabel] = useState<string>("Placement Test");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setParts((prev) => Array.from(Array(numberOfParts)).map((_, i) => (!!prev.at(i) ? prev.at(i)! : { quantity: 10, type: "multiple_choice_4" })));
|
setParts((prev) => Array.from(Array(numberOfParts)).map((_, i) => (!!prev.at(i) ? prev.at(i)! : { quantity: 10, type: "multiple_choice_4" })));
|
||||||
@@ -301,6 +424,8 @@ const LevelGeneration = ({ id } : Props) => {
|
|||||||
difficulty,
|
difficulty,
|
||||||
variant: "full",
|
variant: "full",
|
||||||
isDiagnostic: false,
|
isDiagnostic: false,
|
||||||
|
private: isPrivate,
|
||||||
|
label: label,
|
||||||
parts: parts
|
parts: parts
|
||||||
.map((part, index) => {
|
.map((part, index) => {
|
||||||
const currentExercise = result.data.exercises[`exercise_${index + 1}`] as any;
|
const currentExercise = result.data.exercises[`exercise_${index + 1}`] as any;
|
||||||
@@ -314,8 +439,8 @@ const LevelGeneration = ({ id } : Props) => {
|
|||||||
id: v4(),
|
id: v4(),
|
||||||
prompt:
|
prompt:
|
||||||
part.type === "multiple_choice_underlined"
|
part.type === "multiple_choice_underlined"
|
||||||
? "Select the wrong part of the sentence."
|
? "Choose the underlined word or group of words that is not correct.\nFor each question, select your choice (A, B, C or D)."
|
||||||
: "Select the appropriate option.",
|
: "Choose the correct word or group of words that completes the sentences below.\nFor each question, select the correct letter (A, B, C or D).",
|
||||||
questions: currentExercise.questions.map((x: any) => ({ ...x, variant: "text" })),
|
questions: currentExercise.questions.map((x: any) => ({ ...x, variant: "text" })),
|
||||||
type: "multipleChoice",
|
type: "multipleChoice",
|
||||||
userSolutions: [],
|
userSolutions: [],
|
||||||
@@ -323,6 +448,38 @@ const LevelGeneration = ({ id } : Props) => {
|
|||||||
|
|
||||||
const item = {
|
const item = {
|
||||||
exercises: [exercise],
|
exercises: [exercise],
|
||||||
|
intro: parts[index].part?.intro,
|
||||||
|
category: parts[index].part?.category
|
||||||
|
};
|
||||||
|
|
||||||
|
newParts = newParts.map((p, i) =>
|
||||||
|
i === index
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
part: item,
|
||||||
|
}
|
||||||
|
: p,
|
||||||
|
);
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type === "fill_blanks_mc") {
|
||||||
|
const exercise: FillBlanksExercise = {
|
||||||
|
id: v4(),
|
||||||
|
prompt: "Read the text below and choose the correct word for each space.\nFor each question, select your choice (A, B, C or D). ",
|
||||||
|
text: currentExercise.text,
|
||||||
|
words: currentExercise.words,
|
||||||
|
solutions: currentExercise.solutions,
|
||||||
|
type: "fillBlanks",
|
||||||
|
variant: "mc",
|
||||||
|
userSolutions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
exercises: [exercise],
|
||||||
|
intro: parts[index].part?.intro,
|
||||||
|
category: parts[index].part?.category
|
||||||
};
|
};
|
||||||
|
|
||||||
newParts = newParts.map((p, i) =>
|
newParts = newParts.map((p, i) =>
|
||||||
@@ -338,8 +495,6 @@ const LevelGeneration = ({ id } : Props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (part.type === "blank_space_text") {
|
if (part.type === "blank_space_text") {
|
||||||
console.log({currentExercise});
|
|
||||||
|
|
||||||
const exercise: WriteBlanksExercise = {
|
const exercise: WriteBlanksExercise = {
|
||||||
id: v4(),
|
id: v4(),
|
||||||
prompt: "Complete the text below.",
|
prompt: "Complete the text below.",
|
||||||
@@ -352,6 +507,8 @@ const LevelGeneration = ({ id } : Props) => {
|
|||||||
|
|
||||||
const item = {
|
const item = {
|
||||||
exercises: [exercise],
|
exercises: [exercise],
|
||||||
|
intro: parts[index].part?.intro,
|
||||||
|
category: parts[index].part?.category
|
||||||
};
|
};
|
||||||
|
|
||||||
newParts = newParts.map((p, i) =>
|
newParts = newParts.map((p, i) =>
|
||||||
@@ -390,6 +547,8 @@ const LevelGeneration = ({ id } : Props) => {
|
|||||||
const item = {
|
const item = {
|
||||||
context: currentExercise.text.content,
|
context: currentExercise.text.content,
|
||||||
exercises: [mcExercise, wbExercise],
|
exercises: [mcExercise, wbExercise],
|
||||||
|
intro: parts[index].part?.intro,
|
||||||
|
category: parts[index].part?.category
|
||||||
};
|
};
|
||||||
|
|
||||||
newParts = newParts.map((p, i) =>
|
newParts = newParts.map((p, i) =>
|
||||||
@@ -431,10 +590,27 @@ const LevelGeneration = ({ id } : Props) => {
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
parts.forEach((part) => {
|
||||||
|
part.part!.exercises.forEach((exercise, i) => {
|
||||||
|
switch(exercise.type) {
|
||||||
|
case 'fillBlanks':
|
||||||
|
exercise.prompt.replaceAll('\n', '\\n')
|
||||||
|
break;
|
||||||
|
case 'multipleChoice':
|
||||||
|
exercise.prompt.replaceAll('\n', '\\n')
|
||||||
|
break;
|
||||||
|
case 'writeBlanks':
|
||||||
|
exercise.prompt.replaceAll('\n', '\\n')
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
const exam = {
|
const exam = {
|
||||||
...generatedExam,
|
...generatedExam,
|
||||||
id,
|
id,
|
||||||
parts: generatedExam.parts.map((p, i) => ({...p, exercises: parts[i].part!.exercises})),
|
label: label,
|
||||||
|
parts: generatedExam.parts.map((p, i) => ({ ...p, exercises: parts[i].part!.exercises, category: parts[i].part?.category, intro: parts[i].part?.intro?.replaceAll('\n', '\\n') })),
|
||||||
};
|
};
|
||||||
|
|
||||||
axios
|
axios
|
||||||
@@ -456,8 +632,8 @@ const LevelGeneration = ({ id } : Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex gap-4 w-full">
|
<div className="flex gap-4 w-full items-center">
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-1/2">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||||
<Select
|
<Select
|
||||||
options={DIFFICULTIES.map((x) => ({
|
options={DIFFICULTIES.map((x) => ({
|
||||||
@@ -468,14 +644,32 @@ const LevelGeneration = ({ id } : Props) => {
|
|||||||
value={{ value: difficulty, label: capitalize(difficulty) }}
|
value={{ value: difficulty, label: capitalize(difficulty) }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-1/3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Number of Parts</label>
|
<label className="font-normal text-base text-mti-gray-dim">Number of Parts</label>
|
||||||
<Input type="number" name="Number of Parts" onChange={(v) => setNumberOfParts(parseInt(v))} value={numberOfParts} />
|
<Input type="number" name="Number of Parts" onChange={(v) => setNumberOfParts(parseInt(v))} value={numberOfParts} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-1/3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Timer (in minutes)</label>
|
<label className="font-normal text-base text-mti-gray-dim">Timer (in minutes)</label>
|
||||||
<Input type="number" name="Timer (in minutes)" onChange={(v) => setTimer(parseInt(v))} value={timer} />
|
<Input type="number" name="Timer (in minutes)" onChange={(v) => setTimer(parseInt(v))} value={timer} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 w-fit h-fit">
|
||||||
|
<div className="h-6" />
|
||||||
|
<Checkbox isChecked={isPrivate} onChange={setPrivate}>
|
||||||
|
Privacy (Only available for Assignments)
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Exam Label *</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Label"
|
||||||
|
name="label"
|
||||||
|
onChange={(e) => setLabel(e)}
|
||||||
|
roundness="xl"
|
||||||
|
defaultValue={label}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
|
||||||
@@ -498,6 +692,8 @@ const LevelGeneration = ({ id } : Props) => {
|
|||||||
{Array.from(Array(numberOfParts), (_, index) => index).map((index) => (
|
{Array.from(Array(numberOfParts), (_, index) => index).map((index) => (
|
||||||
<TaskTab
|
<TaskTab
|
||||||
key={index}
|
key={index}
|
||||||
|
label={label}
|
||||||
|
index={index}
|
||||||
section={parts[index]}
|
section={parts[index]}
|
||||||
setSection={(part) => {
|
setSection={(part) => {
|
||||||
console.log(part);
|
console.log(part);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
|||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import WriteBlanksEdit from "@/components/Generation/write.blanks.edit";
|
import WriteBlanksEdit from "@/components/Generation/write.blanks.edit";
|
||||||
import {generate} from "random-words";
|
import {generate} from "random-words";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
|
||||||
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||||
|
|
||||||
@@ -241,6 +242,7 @@ const ListeningGeneration = ({ id } : Props) => {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [resultingExam, setResultingExam] = useState<ListeningExam>();
|
const [resultingExam, setResultingExam] = useState<ListeningExam>();
|
||||||
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||||
|
const [isPrivate, setPrivate] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const part1Timer = part1 ? 5 : 0;
|
const part1Timer = part1 ? 5 : 0;
|
||||||
@@ -275,6 +277,7 @@ const ListeningGeneration = ({ id } : Props) => {
|
|||||||
parts,
|
parts,
|
||||||
minTimer,
|
minTimer,
|
||||||
difficulty,
|
difficulty,
|
||||||
|
private: isPrivate,
|
||||||
})
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
playSound("sent");
|
playSound("sent");
|
||||||
@@ -313,7 +316,7 @@ const ListeningGeneration = ({ id } : Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex gap-4 w-1/2">
|
<div className="flex gap-4 w-full items-center">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -324,7 +327,7 @@ const ListeningGeneration = ({ id } : Props) => {
|
|||||||
className="max-w-[300px]"
|
className="max-w-[300px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-1/2">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||||
<Select
|
<Select
|
||||||
options={DIFFICULTIES.map((x) => ({
|
options={DIFFICULTIES.map((x) => ({
|
||||||
@@ -336,6 +339,12 @@ const ListeningGeneration = ({ id } : Props) => {
|
|||||||
disabled={!!part1 || !!part2 || !!part3 || !!part4}
|
disabled={!!part1 || !!part2 || !!part3 || !!part4}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 w-fit h-fit">
|
||||||
|
<div className="h-6" />
|
||||||
|
<Checkbox isChecked={isPrivate} onChange={setPrivate}>
|
||||||
|
Privacy (Only available for Assignments)
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-listening/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-listening/20 p-1">
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import TrueFalseEdit from "@/components/Generation/true.false.edit";
|
|||||||
import WriteBlanksEdit from "@/components/Generation/write.blanks.edit";
|
import WriteBlanksEdit from "@/components/Generation/write.blanks.edit";
|
||||||
import MatchSentencesEdit from "@/components/Generation/match.sentences.edit";
|
import MatchSentencesEdit from "@/components/Generation/match.sentences.edit";
|
||||||
import MultipleChoiceEdit from "@/components/Generation/multiple.choice.edit";
|
import MultipleChoiceEdit from "@/components/Generation/multiple.choice.edit";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
|
||||||
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||||
|
|
||||||
@@ -270,6 +271,7 @@ const ReadingGeneration = ({ id } : Props) => {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [resultingExam, setResultingExam] = useState<ReadingExam>();
|
const [resultingExam, setResultingExam] = useState<ReadingExam>();
|
||||||
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||||
|
const [isPrivate, setPrivate] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const parts = [part1, part2, part3].filter((x) => !!x);
|
const parts = [part1, part2, part3].filter((x) => !!x);
|
||||||
@@ -319,6 +321,7 @@ const ReadingGeneration = ({ id } : Props) => {
|
|||||||
type: "academic",
|
type: "academic",
|
||||||
variant: parts.length === 3 ? "full" : "partial",
|
variant: parts.length === 3 ? "full" : "partial",
|
||||||
difficulty,
|
difficulty,
|
||||||
|
private: isPrivate,
|
||||||
};
|
};
|
||||||
|
|
||||||
axios
|
axios
|
||||||
@@ -344,7 +347,7 @@ const ReadingGeneration = ({ id } : Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex gap-4 w-1/2">
|
<div className="flex gap-4 w-full items-center">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -355,7 +358,7 @@ const ReadingGeneration = ({ id } : Props) => {
|
|||||||
className="max-w-[300px]"
|
className="max-w-[300px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-1/2">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||||
<Select
|
<Select
|
||||||
options={DIFFICULTIES.map((x) => ({
|
options={DIFFICULTIES.map((x) => ({
|
||||||
@@ -367,6 +370,12 @@ const ReadingGeneration = ({ id } : Props) => {
|
|||||||
disabled={!!part1 || !!part2 || !!part3}
|
disabled={!!part1 || !!part2 || !!part3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 w-fit h-fit">
|
||||||
|
<div className="h-6" />
|
||||||
|
<Checkbox isChecked={isPrivate} onChange={setPrivate}>
|
||||||
|
Privacy (Only available for Assignments)
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-reading/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-reading/20 p-1">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
import {Difficulty, Exercise, InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise} from "@/interfaces/exam";
|
import {Difficulty, Exercise, InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise} from "@/interfaces/exam";
|
||||||
@@ -233,6 +234,7 @@ const SpeakingGeneration = ({ id } : Props) => {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [resultingExam, setResultingExam] = useState<SpeakingExam>();
|
const [resultingExam, setResultingExam] = useState<SpeakingExam>();
|
||||||
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||||
|
const [isPrivate, setPrivate] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const parts = [part1, part2, part3].filter((x) => !!x);
|
const parts = [part1, part2, part3].filter((x) => !!x);
|
||||||
@@ -272,6 +274,7 @@ const SpeakingGeneration = ({ id } : Props) => {
|
|||||||
variant: minTimer >= 14 ? "full" : "partial",
|
variant: minTimer >= 14 ? "full" : "partial",
|
||||||
module: "speaking",
|
module: "speaking",
|
||||||
instructorGender: genders.every((x) => x === "male") ? "male" : genders.every((x) => x === "female") ? "female" : "varied",
|
instructorGender: genders.every((x) => x === "male") ? "male" : genders.every((x) => x === "female") ? "female" : "varied",
|
||||||
|
private: isPrivate,
|
||||||
};
|
};
|
||||||
|
|
||||||
axios
|
axios
|
||||||
@@ -313,7 +316,7 @@ const SpeakingGeneration = ({ id } : Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex gap-4 w-1/2">
|
<div className="flex gap-4 w-full items-center">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -324,7 +327,7 @@ const SpeakingGeneration = ({ id } : Props) => {
|
|||||||
className="max-w-[300px]"
|
className="max-w-[300px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-1/2">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||||
<Select
|
<Select
|
||||||
options={DIFFICULTIES.map((x) => ({
|
options={DIFFICULTIES.map((x) => ({
|
||||||
@@ -336,6 +339,13 @@ const SpeakingGeneration = ({ id } : Props) => {
|
|||||||
disabled={!!part1 || !!part2 || !!part3}
|
disabled={!!part1 || !!part2 || !!part3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 w-fit h-fit">
|
||||||
|
<div className="h-6" />
|
||||||
|
<Checkbox isChecked={isPrivate} onChange={setPrivate}>
|
||||||
|
Privacy (Only available for Assignments)
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
import {Difficulty, WritingExam, WritingExercise} from "@/interfaces/exam";
|
import {Difficulty, WritingExam, WritingExercise} from "@/interfaces/exam";
|
||||||
@@ -86,6 +87,7 @@ const WritingGeneration = ({ id } : Props) => {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [resultingExam, setResultingExam] = useState<WritingExam>();
|
const [resultingExam, setResultingExam] = useState<WritingExam>();
|
||||||
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||||
|
const [isPrivate, setPrivate] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const task1Timer = task1 ? 20 : 0;
|
const task1Timer = task1 ? 20 : 0;
|
||||||
@@ -164,6 +166,7 @@ const WritingGeneration = ({ id } : Props) => {
|
|||||||
id,
|
id,
|
||||||
variant: exercise1 && exercise2 ? "full" : "partial",
|
variant: exercise1 && exercise2 ? "full" : "partial",
|
||||||
difficulty,
|
difficulty,
|
||||||
|
private: isPrivate,
|
||||||
};
|
};
|
||||||
|
|
||||||
axios
|
axios
|
||||||
@@ -188,7 +191,7 @@ const WritingGeneration = ({ id } : Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex gap-4 w-1/2">
|
<div className="flex gap-4 w-full items-center">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -199,7 +202,7 @@ const WritingGeneration = ({ id } : Props) => {
|
|||||||
className="max-w-[300px]"
|
className="max-w-[300px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-1/2">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||||
<Select
|
<Select
|
||||||
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
|
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
|
||||||
@@ -208,6 +211,13 @@ const WritingGeneration = ({ id } : Props) => {
|
|||||||
disabled={!!task1 || !!task2}
|
disabled={!!task1 || !!task2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 w-fit h-fit">
|
||||||
|
<div className="h-6" />
|
||||||
|
<Checkbox isChecked={isPrivate} onChange={setPrivate}>
|
||||||
|
Privacy (Only available for Assignments)
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
|
|||||||
@@ -160,15 +160,20 @@ export default function PaymentDue({user, hasExpired = false, reload}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isIndividual() && user.type === "corporate" && user?.corporateInformation.payment && (
|
{!isIndividual() &&
|
||||||
|
(user?.type === "corporate" || user?.type === "mastercorporate") &&
|
||||||
|
user?.corporateInformation.payment && (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<span className="max-w-lg">
|
<span className="max-w-lg">
|
||||||
To add to your use of EnCoach and that of your students and teachers, please pay your designated package below:
|
To add to your use of EnCoach and that of your students and teachers, please pay your designated package
|
||||||
|
below:
|
||||||
</span>
|
</span>
|
||||||
<div className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}>
|
<div className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}>
|
||||||
<div className="mb-2 flex flex-col items-start">
|
<div className="mb-2 flex flex-col items-start">
|
||||||
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
|
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
|
||||||
<span className="text-xl font-semibold">EnCoach - {user.corporateInformation?.monthlyDuration} Months</span>
|
<span className="text-xl font-semibold">
|
||||||
|
EnCoach - {user.corporateInformation?.monthlyDuration} Months
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col items-start gap-2">
|
<div className="flex w-full flex-col items-start gap-2">
|
||||||
<span className="text-2xl">
|
<span className="text-2xl">
|
||||||
@@ -191,8 +196,8 @@ export default function PaymentDue({user, hasExpired = false, reload}: Props) {
|
|||||||
<span>This includes:</span>
|
<span>This includes:</span>
|
||||||
<ul className="flex flex-col items-start text-sm">
|
<ul className="flex flex-col items-start text-sm">
|
||||||
<li>
|
<li>
|
||||||
- Allow a total of {user.corporateInformation.companyInformation.userAmount} students and teachers to
|
- Allow a total of {user.corporateInformation.companyInformation.userAmount} students and teachers
|
||||||
use EnCoach
|
to use EnCoach
|
||||||
</li>
|
</li>
|
||||||
<li>- Train their abilities for the IELTS exam</li>
|
<li>- Train their abilities for the IELTS exam</li>
|
||||||
<li>- Gain insights into your students' weaknesses and strengths</li>
|
<li>- Gain insights into your students' weaknesses and strengths</li>
|
||||||
@@ -202,7 +207,7 @@ export default function PaymentDue({user, hasExpired = false, reload}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isIndividual() && user.type !== "corporate" && (
|
{!isIndividual() && !(user?.type === "corporate" || user?.type === "mastercorporate") && (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<span className="max-w-lg">
|
<span className="max-w-lg">
|
||||||
You are not the person in charge of your time credits, please contact your administrator about this situation.
|
You are not the person in charge of your time credits, please contact your administrator about this situation.
|
||||||
@@ -213,11 +218,13 @@ export default function PaymentDue({user, hasExpired = false, reload}: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isIndividual() && user.type === "corporate" && !user.corporateInformation.payment && (
|
{!isIndividual() &&
|
||||||
|
(user?.type === "corporate" || user?.type === "mastercorporate") &&
|
||||||
|
!user.corporateInformation.payment && (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<span className="max-w-lg">
|
<span className="max-w-lg">
|
||||||
An admin nor your agent have yet set the price intended to your requirements in terms of the amount of users you
|
An admin nor your agent have yet set the price intended to your requirements in terms of the amount of users
|
||||||
desire and your expected monthly duration.
|
you desire and your expected monthly duration.
|
||||||
</span>
|
</span>
|
||||||
<span className="max-w-lg">
|
<span className="max-w-lg">
|
||||||
Please try again later or contact your agent or an admin, thank you for your patience.
|
Please try again later or contact your agent or an admin, thank you for your patience.
|
||||||
|
|||||||
@@ -84,9 +84,16 @@ function commonExcel({
|
|||||||
.map((assignee: string) => {
|
.map((assignee: string) => {
|
||||||
const userStats = allStats.filter((s: any) => s.user === assignee);
|
const userStats = allStats.filter((s: any) => s.user === assignee);
|
||||||
const dates = userStats.map((s: any) => moment(s.date));
|
const dates = userStats.map((s: any) => moment(s.date));
|
||||||
|
const user = users.find((u) => u.id === assignee);
|
||||||
return {
|
return {
|
||||||
userId: assignee,
|
userId: assignee,
|
||||||
user: users.find((u) => u.id === assignee),
|
// added some default values in case the user is not found
|
||||||
|
// could it be possible to have an assigned user deleted from the database?
|
||||||
|
user: user || {
|
||||||
|
name: "Unknown",
|
||||||
|
email: "Unknown",
|
||||||
|
demographicInformation: { passportId: "Unknown", gender: "Unknown" },
|
||||||
|
},
|
||||||
...userStats.reduce(
|
...userStats.reduce(
|
||||||
(acc: any, curr: any) => {
|
(acc: any, curr: any) => {
|
||||||
return {
|
return {
|
||||||
@@ -188,19 +195,24 @@ function commonExcel({
|
|||||||
worksheet.addRow(tableColumnHeaders);
|
worksheet.addRow(tableColumnHeaders);
|
||||||
|
|
||||||
// 1 headers rows
|
// 1 headers rows
|
||||||
const startIndexTable = firstSectionData.length + customTableAndLine.length + 1;
|
const startIndexTable =
|
||||||
|
firstSectionData.length + customTableAndLine.length + 1;
|
||||||
|
|
||||||
// // Merge "Test Sections" over dynamic number of columns
|
// // Merge "Test Sections" over dynamic number of columns
|
||||||
// const tableColumns = staticHeaders.length + numberOfTestSections;
|
// const tableColumns = staticHeaders.length + numberOfTestSections;
|
||||||
|
|
||||||
// K10:M12 = 10,11,12,13
|
// K10:M12 = 10,11,12,13
|
||||||
// horizontally group Test Sections
|
// horizontally group Test Sections
|
||||||
|
|
||||||
|
// if there are test section headers to even merge:
|
||||||
|
if (testSectionHeaders.length > 1) {
|
||||||
worksheet.mergeCells(
|
worksheet.mergeCells(
|
||||||
startIndexTable,
|
startIndexTable,
|
||||||
staticHeaders.length + 1,
|
staticHeaders.length + 1,
|
||||||
startIndexTable,
|
startIndexTable,
|
||||||
tableColumnHeadersFirstPart.length
|
tableColumnHeadersFirstPart.length
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Add the dynamic second and third header rows for test sections and sub-columns
|
// Add the dynamic second and third header rows for test sections and sub-columns
|
||||||
worksheet.addRow([
|
worksheet.addRow([
|
||||||
@@ -229,7 +241,12 @@ function commonExcel({
|
|||||||
|
|
||||||
// vertically group based on the part, exercise and type
|
// vertically group based on the part, exercise and type
|
||||||
staticHeaders.forEach((header, index) => {
|
staticHeaders.forEach((header, index) => {
|
||||||
worksheet.mergeCells(startIndexTable, index + 1, startIndexTable + 3, index + 1);
|
worksheet.mergeCells(
|
||||||
|
startIndexTable,
|
||||||
|
index + 1,
|
||||||
|
startIndexTable + 3,
|
||||||
|
index + 1
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
assigneesData.forEach((data, index) => {
|
assigneesData.forEach((data, index) => {
|
||||||
@@ -316,13 +333,17 @@ async function mastercorporateAssignment(
|
|||||||
const adminsData = await getSpecificUsers(adminUsers);
|
const adminsData = await getSpecificUsers(adminUsers);
|
||||||
const companiesData = adminsData.map((user) => {
|
const companiesData = adminsData.map((user) => {
|
||||||
const name = getUserName(user);
|
const name = getUserName(user);
|
||||||
const users = userGroupsParticipants
|
const users = userGroupsParticipants.filter((p) =>
|
||||||
.filter((p) => data.assignees.includes(p));
|
data.assignees.includes(p)
|
||||||
|
);
|
||||||
|
|
||||||
const stats = data.results
|
const stats = data.results
|
||||||
.flatMap((r: any) => r.stats)
|
.flatMap((r: any) => r.stats)
|
||||||
.filter((s: any) => users.includes(s.user));
|
.filter((s: any) => users.includes(s.user));
|
||||||
const correct = stats.reduce((acc: number, s: any) => acc + s.score.correct, 0);
|
const correct = stats.reduce(
|
||||||
|
(acc: number, s: any) => acc + s.score.correct,
|
||||||
|
0
|
||||||
|
);
|
||||||
const total = stats.reduce(
|
const total = stats.reduce(
|
||||||
(acc: number, curr: any) => acc + curr.score.total,
|
(acc: number, curr: any) => acc + curr.score.total,
|
||||||
0
|
0
|
||||||
@@ -342,9 +363,11 @@ async function mastercorporateAssignment(
|
|||||||
correct: companiesData.reduce((acc, curr) => acc + curr.correct, 0),
|
correct: companiesData.reduce((acc, curr) => acc + curr.correct, 0),
|
||||||
total: companiesData.reduce((acc, curr) => acc + curr.total, 0),
|
total: companiesData.reduce((acc, curr) => acc + curr.total, 0),
|
||||||
},
|
},
|
||||||
].map((c) => [c.name, `${c.correct}/${c.total}`])
|
].map((c) => [c.name, `${c.correct}/${c.total}`]);
|
||||||
|
|
||||||
const customTableHeaders = [{ name: "Corporate", helper: (data: any) => data.user.corporateName}];
|
const customTableHeaders = [
|
||||||
|
{ name: "Corporate", helper: (data: any) => data.user.corporateName },
|
||||||
|
];
|
||||||
return commonExcel({
|
return commonExcel({
|
||||||
data,
|
data,
|
||||||
userName: user.corporateInformation?.companyInformation?.name || "",
|
userName: user.corporateInformation?.companyInformation?.name || "",
|
||||||
@@ -354,12 +377,13 @@ async function mastercorporateAssignment(
|
|||||||
return {
|
return {
|
||||||
...u,
|
...u,
|
||||||
corporateName: getUserName(admin),
|
corporateName: getUserName(admin),
|
||||||
}
|
};
|
||||||
}),
|
}),
|
||||||
sectionName: "Master Corporate Name :",
|
sectionName: "Master Corporate Name :",
|
||||||
customTable: [['Corporate Summary'], ...customTable],
|
customTable: [["Corporate Summary"], ...customTable],
|
||||||
customTableHeaders: customTableHeaders.map((h) => h.name),
|
customTableHeaders: customTableHeaders.map((h) => h.name),
|
||||||
renderCustomTableData: (data) => customTableHeaders.map((h) => h.helper(data)),
|
renderCustomTableData: (data) =>
|
||||||
|
customTableHeaders.map((h) => h.helper(data)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,7 +435,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
case "corporate":
|
case "corporate":
|
||||||
return corporateAssignment(user as CorporateUser, data, users);
|
return corporateAssignment(user as CorporateUser, data, users);
|
||||||
case "mastercorporate":
|
case "mastercorporate":
|
||||||
return mastercorporateAssignment(user as MasterCorporateUser, data, users);
|
return mastercorporateAssignment(
|
||||||
|
user as MasterCorporateUser,
|
||||||
|
data,
|
||||||
|
users
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
throw new Error("Invalid user type");
|
throw new Error("Invalid user type");
|
||||||
}
|
}
|
||||||
|
|||||||
33
src/pages/api/assignments/[id]/release.ts
Normal file
33
src/pages/api/assignments/[id]/release.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { app } from "@/firebase";
|
||||||
|
import { getFirestore, doc, getDoc, setDoc } from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
// verify if it's a logged user that is trying to archive
|
||||||
|
if (req.session.user) {
|
||||||
|
const { id } = req.query as { id: string };
|
||||||
|
const docSnap = await getDoc(doc(db, "assignments", id));
|
||||||
|
|
||||||
|
if (!docSnap.exists()) {
|
||||||
|
res.status(404).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await setDoc(docSnap.ref, { released: true }, { merge: true });
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(401).json({ ok: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "POST") return post(req, res);
|
||||||
|
res.status(404).json({ ok: false });
|
||||||
|
}
|
||||||
46
src/pages/api/assignments/[id]/start.ts
Normal file
46
src/pages/api/assignments/[id]/start.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { app } from "@/firebase";
|
||||||
|
import moment from "moment";
|
||||||
|
import { getFirestore, doc, getDoc, setDoc } from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
// verify if it's a logged user that is trying to archive
|
||||||
|
if (req.session.user) {
|
||||||
|
const { id } = req.query as { id: string };
|
||||||
|
const docSnap = await getDoc(doc(db, "assignments", id));
|
||||||
|
|
||||||
|
if (!docSnap.exists()) {
|
||||||
|
res.status(404).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = docSnap.data();
|
||||||
|
if (moment().isAfter(moment(data.startDate))) {
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.json({ ok: false, message: "Assignmentcan no longer " });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await setDoc(
|
||||||
|
docSnap.ref,
|
||||||
|
{ start: true },
|
||||||
|
{ merge: true }
|
||||||
|
);
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(401).json({ ok: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "POST") return post(req, res);
|
||||||
|
res.status(404).json({ ok: false });
|
||||||
|
}
|
||||||
@@ -2,9 +2,7 @@
|
|||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import { getAllAssignersByCorporate } from "@/utils/groups.be";
|
import {getAssignmentsByAssigner, getAssignmentsForCorporates} from "@/utils/assignments.be";
|
||||||
import { getAssignmentsByAssigners } from "@/utils/assignments.be";
|
|
||||||
|
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -20,13 +18,18 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const { ids } = req.query as { ids: string };
|
const {ids, startDate, endDate} = req.query as {
|
||||||
|
ids: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDateParsed = startDate ? new Date(startDate) : undefined;
|
||||||
|
const endDateParsed = endDate ? new Date(endDate) : undefined;
|
||||||
try {
|
try {
|
||||||
const idsList = ids.split(",");
|
const idsList = ids.split(",");
|
||||||
|
|
||||||
const assigners = await Promise.all(idsList.map(getAllAssignersByCorporate));
|
const assignments = await getAssignmentsForCorporates(idsList, startDateParsed, endDateParsed);
|
||||||
const assignmentList = [...assigners.flat(), ...idsList];
|
|
||||||
const assignments = await getAssignmentsByAssigners(assignmentList);
|
|
||||||
res.status(200).json(assignments);
|
res.status(200).json(assignments);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({error: err.message});
|
res.status(500).json({error: err.message});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {capitalize, flatten, uniqBy} from "lodash";
|
|||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {sendEmail} from "@/email";
|
import {sendEmail} from "@/email";
|
||||||
|
import {release} from "os";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ const generateExams = async (
|
|||||||
generateMultiple: Boolean,
|
generateMultiple: Boolean,
|
||||||
selectedModules: Module[],
|
selectedModules: Module[],
|
||||||
assignees: string[],
|
assignees: string[],
|
||||||
|
userId: string,
|
||||||
variant?: Variant,
|
variant?: Variant,
|
||||||
instructorGender?: InstructorGender,
|
instructorGender?: InstructorGender,
|
||||||
): Promise<ExamWithUser[]> => {
|
): Promise<ExamWithUser[]> => {
|
||||||
@@ -86,7 +88,7 @@ const generateExams = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedModulePromises = selectedModules.map(async (module: Module) => {
|
const selectedModulePromises = selectedModules.map(async (module: Module) => {
|
||||||
const exams: Exam[] = await getExams(db, module, "false", undefined, variant, instructorGender);
|
const exams: Exam[] = await getExams(db, module, "false", userId, variant, instructorGender);
|
||||||
const exam = exams[getRandomIndex(exams)];
|
const exam = exams[getRandomIndex(exams)];
|
||||||
|
|
||||||
if (exam) {
|
if (exam) {
|
||||||
@@ -121,11 +123,12 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
endDate: string;
|
endDate: string;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
instructorGender?: InstructorGender;
|
instructorGender?: InstructorGender;
|
||||||
|
released: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const exams: ExamWithUser[] = !!examIDs
|
const exams: ExamWithUser[] = !!examIDs
|
||||||
? examIDs.flatMap((e) => assignees.map((a) => ({...e, assignee: a})))
|
? examIDs.flatMap((e) => assignees.map((a) => ({...e, assignee: a})))
|
||||||
: await generateExams(generateMultiple, selectedModules, assignees, variant, instructorGender);
|
: await generateExams(generateMultiple, selectedModules, assignees, req.session.user!.id, variant, instructorGender);
|
||||||
|
|
||||||
if (exams.length === 0) {
|
if (exams.length === 0) {
|
||||||
res.status(400).json({ok: false, error: "No exams found for the selected modules"});
|
res.status(400).json({ok: false, error: "No exams found for the selected modules"});
|
||||||
|
|||||||
243
src/pages/api/assignments/statistical/excel.ts
Normal file
243
src/pages/api/assignments/statistical/excel.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { app, storage } from "@/firebase";
|
||||||
|
import { getFirestore } from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
|
||||||
|
import { AssignmentWithCorporateId } from "@/interfaces/results";
|
||||||
|
import moment from "moment-timezone";
|
||||||
|
import ExcelJS from "exceljs";
|
||||||
|
import { getSpecificUsers } from "@/utils/users.be";
|
||||||
|
import { checkAccess } from "@/utils/permissions";
|
||||||
|
import { getAssignmentsForCorporates } from "@/utils/assignments.be";
|
||||||
|
import { search } from "@/utils/search";
|
||||||
|
import { getGradingSystem } from "@/utils/grading.be";
|
||||||
|
import { Exam } from "@/interfaces/exam";
|
||||||
|
import { User } from "@/interfaces/user";
|
||||||
|
import { calculateBandScore, getGradingLabel } from "@/utils/score";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
interface TableData {
|
||||||
|
user: string;
|
||||||
|
email: string;
|
||||||
|
correct: number;
|
||||||
|
corporate: string;
|
||||||
|
submitted: boolean;
|
||||||
|
date: moment.Moment;
|
||||||
|
assignment: string;
|
||||||
|
corporateId: string;
|
||||||
|
score: number;
|
||||||
|
level: string;
|
||||||
|
part1?: string;
|
||||||
|
part2?: string;
|
||||||
|
part3?: string;
|
||||||
|
part4?: string;
|
||||||
|
part5?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
// if (req.method === "GET") return get(req, res);
|
||||||
|
if (req.method === "POST") return await post(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchFilters = [["email"], ["user"], ["userId"]];
|
||||||
|
|
||||||
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
// verify if it's a logged user that is trying to export
|
||||||
|
if (req.session.user) {
|
||||||
|
if (
|
||||||
|
!checkAccess(req.session.user, ["mastercorporate", "developer", "admin"])
|
||||||
|
) {
|
||||||
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
const { ids, startDate, endDate, searchText } = req.body as {
|
||||||
|
ids: string[];
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
searchText: string;
|
||||||
|
};
|
||||||
|
const startDateParsed = startDate ? new Date(startDate) : undefined;
|
||||||
|
const endDateParsed = endDate ? new Date(endDate) : undefined;
|
||||||
|
const assignments = await getAssignmentsForCorporates(
|
||||||
|
ids,
|
||||||
|
startDateParsed,
|
||||||
|
endDateParsed
|
||||||
|
);
|
||||||
|
|
||||||
|
const assignmentUsers = [
|
||||||
|
...new Set(assignments.flatMap((a) => a.assignees)),
|
||||||
|
];
|
||||||
|
const assigners = [...new Set(assignments.map((a) => a.assigner))];
|
||||||
|
const users = await getSpecificUsers(assignmentUsers);
|
||||||
|
const assignerUsers = await getSpecificUsers(assigners);
|
||||||
|
|
||||||
|
const assignerUsersGradingSystems = await Promise.all(
|
||||||
|
assignerUsers.map(async (user: User) => {
|
||||||
|
const data = await getGradingSystem(user);
|
||||||
|
// in this context I need to override as I'll have to match to the assigner
|
||||||
|
return { ...data, user: user.id };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const getGradingSystemHelper = (
|
||||||
|
exams: {id: string; module: Module; assignee: string}[],
|
||||||
|
assigner: string,
|
||||||
|
user: User,
|
||||||
|
correct: number,
|
||||||
|
total: number
|
||||||
|
) => {
|
||||||
|
if (exams.some((e) => e.module === "level")) {
|
||||||
|
const gradingSystem = assignerUsersGradingSystems.find(
|
||||||
|
(gs) => gs.user === assigner
|
||||||
|
);
|
||||||
|
if (gradingSystem) {
|
||||||
|
const bandScore = calculateBandScore(
|
||||||
|
correct,
|
||||||
|
total,
|
||||||
|
"level",
|
||||||
|
user.focus
|
||||||
|
);
|
||||||
|
return { label: getGradingLabel(bandScore, gradingSystem?.steps || []), score: bandScore };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { score: -1, label: "N/A" };
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableResults = 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 corporateUser = users.find((u) => u.id === a.assigner);
|
||||||
|
const correct = userStats.reduce((n, e) => n + e.score.correct, 0);
|
||||||
|
const total = userStats.reduce((n, e) => n + e.score.total, 0);
|
||||||
|
const { label: level, score } = getGradingSystemHelper(
|
||||||
|
a.exams,
|
||||||
|
a.assigner,
|
||||||
|
userData!,
|
||||||
|
correct,
|
||||||
|
total
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
console.log("Level", level);
|
||||||
|
const commonData = {
|
||||||
|
user: userData?.name || "",
|
||||||
|
email: userData?.email || "",
|
||||||
|
userId: assignee,
|
||||||
|
corporateId: a.corporateId,
|
||||||
|
corporate: corporateUser?.name || "",
|
||||||
|
assignment: a.name,
|
||||||
|
level,
|
||||||
|
score,
|
||||||
|
};
|
||||||
|
if (userStats.length === 0) {
|
||||||
|
return {
|
||||||
|
...commonData,
|
||||||
|
correct: 0,
|
||||||
|
submitted: false,
|
||||||
|
// date: moment(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const partsData = userStats.every((e) => e.module === "level") ? userStats.reduce((acc, e, index) => {
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
[`part${index}`]: `${e.score.correct}/${e.score.total}`
|
||||||
|
}
|
||||||
|
}, {}) : {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...commonData,
|
||||||
|
correct,
|
||||||
|
submitted: true,
|
||||||
|
date: moment.max(userStats.map((e) => moment(e.date))),
|
||||||
|
...partsData,
|
||||||
|
};
|
||||||
|
}) as TableData[];
|
||||||
|
|
||||||
|
return [...accmA, ...userResults];
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
).sort((a,b) => b.score - a.score);
|
||||||
|
|
||||||
|
// Create a new workbook and add a worksheet
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const worksheet = workbook.addWorksheet("Master Statistical");
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
{
|
||||||
|
label: "User",
|
||||||
|
value: (entry: TableData) => entry.user,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Email",
|
||||||
|
value: (entry: TableData) => entry.email,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Corporate",
|
||||||
|
value: (entry: TableData) => entry.corporate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Assignment",
|
||||||
|
value: (entry: TableData) => entry.assignment,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Submitted",
|
||||||
|
value: (entry: TableData) => (entry.submitted ? "Yes" : "No"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Correct",
|
||||||
|
value: (entry: TableData) => entry.correct,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Date",
|
||||||
|
value: (entry: TableData) => entry.date?.format("YYYY/MM/DD") || "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Level",
|
||||||
|
value: (entry: TableData) => entry.level,
|
||||||
|
},
|
||||||
|
...new Array(5).fill(0).map((_, index) => ({
|
||||||
|
label: `Part ${index + 1}`,
|
||||||
|
value: (entry: TableData) => {
|
||||||
|
const key = `part${index}` as keyof TableData;
|
||||||
|
return entry[key] || "";
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredSearch = searchText
|
||||||
|
? search(searchText, searchFilters, tableResults)
|
||||||
|
: tableResults;
|
||||||
|
|
||||||
|
worksheet.addRow(headers.map((h) => h.label));
|
||||||
|
(filteredSearch as TableData[]).forEach((entry) => {
|
||||||
|
worksheet.addRow(headers.map((h) => h.value(entry)));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert workbook to Buffer (Node.js) or Blob (Browser)
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
|
||||||
|
// generate the file ref for storage
|
||||||
|
const fileName = `${Date.now().toString()}.xlsx`;
|
||||||
|
const refName = `statistical/${fileName}`;
|
||||||
|
const fileRef = ref(storage, refName);
|
||||||
|
// upload the pdf to storage
|
||||||
|
const snapshot = await uploadBytes(fileRef, buffer, {
|
||||||
|
contentType:
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = await getDownloadURL(fileRef);
|
||||||
|
res.status(200).end(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
61
src/pages/api/batch_users.ts
Normal file
61
src/pages/api/batch_users.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
import { FirebaseScrypt } from 'firebase-scrypt';
|
||||||
|
import { firebaseAuthScryptParams } from "@/firebase";
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "POST") return post(req, res);
|
||||||
|
|
||||||
|
return res.status(404).json({ok: false});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const maker = req.session.user;
|
||||||
|
if (!maker) {
|
||||||
|
return res.status(401).json({ok: false, reason: "You must be logged in to make user!"});
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrypt = new FirebaseScrypt(firebaseAuthScryptParams)
|
||||||
|
|
||||||
|
const users = req.body.users as {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
passport_id: string;
|
||||||
|
groupName?: string;
|
||||||
|
corporate?: string;
|
||||||
|
studentID?: string;
|
||||||
|
expiryDate?: string;
|
||||||
|
demographicInformation: {
|
||||||
|
country?: string;
|
||||||
|
passport_id?: string;
|
||||||
|
phone: string;
|
||||||
|
};
|
||||||
|
passwordHash: string | undefined;
|
||||||
|
passwordSalt: string | undefined;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
const usersWithPasswordHashes = await Promise.all(users.map(async (user) => {
|
||||||
|
const currentUser = { ...user };
|
||||||
|
const salt = crypto.randomBytes(16).toString('base64');
|
||||||
|
const hash = await scrypt.hash(user.passport_id, salt);
|
||||||
|
|
||||||
|
currentUser.email = currentUser.email.toLowerCase();
|
||||||
|
currentUser.passwordHash = hash;
|
||||||
|
currentUser.passwordSalt = salt;
|
||||||
|
return currentUser;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/batch_users`, { makerID: maker.id, users: usersWithPasswordHashes }, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(backendRequest.status).json(backendRequest.data)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user