Compare commits
209 Commits
ENCOA-83_M
...
migration-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdd42b2f07 | ||
|
|
34bc9df9ea | ||
|
|
15cc7c8cc9 | ||
|
|
b4ab620c78 | ||
|
|
6e4ef249b8 | ||
|
|
c2b4bb29d6 | ||
|
|
cab469007b | ||
|
|
d6782bd86e | ||
|
|
6251f8f4db | ||
|
|
fb9d11f38d | ||
|
|
bb8dca69cf | ||
|
|
53b31b306d | ||
|
|
d173cdb02a | ||
|
|
07f0ea25bb | ||
|
|
e7ee55d608 | ||
|
|
7fa4edf37d | ||
|
|
49022394b0 | ||
|
|
3be0d158e3 | ||
|
|
56f374bbfe | ||
|
|
417c9176fe | ||
|
|
e3400e8564 | ||
|
|
d680905a87 | ||
|
|
c07e3f86fb | ||
|
|
238a25aaeb | ||
|
|
171231cd21 | ||
|
|
6ed342bb6f | ||
|
|
6f7ef1abef | ||
|
|
e33fa00fa3 | ||
|
|
c0b814081e | ||
|
|
e8b7c5ff80 | ||
|
|
8c94bcac52 | ||
|
|
8803a8c166 | ||
|
|
2f63fd196b | ||
|
|
42471170ce | ||
|
|
2bf9afca9c | ||
|
|
9c41ddee60 | ||
|
|
9993c7a8a7 | ||
|
|
a22c9d102f | ||
|
|
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 [
|
||||||
|
|||||||
380
package-lock.json
generated
380
package-lock.json
generated
@@ -26,7 +26,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",
|
||||||
@@ -41,6 +42,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",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"moment-timezone": "^0.5.44",
|
"moment-timezone": "^0.5.44",
|
||||||
|
"mongodb": "^6.8.1",
|
||||||
"next": "^14.2.5",
|
"next": "^14.2.5",
|
||||||
"nodemailer": "^6.9.5",
|
"nodemailer": "^6.9.5",
|
||||||
"nodemailer-express-handlebars": "^6.1.0",
|
"nodemailer-express-handlebars": "^6.1.0",
|
||||||
@@ -64,7 +67,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",
|
||||||
@@ -77,7 +80,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",
|
||||||
@@ -88,6 +91,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",
|
||||||
@@ -1945,6 +1949,14 @@
|
|||||||
"prop-types": "^15.7.2"
|
"prop-types": "^15.7.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mongodb-js/saslprep": {
|
||||||
|
"version": "1.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz",
|
||||||
|
"integrity": "sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==",
|
||||||
|
"dependencies": {
|
||||||
|
"sparse-bitfield": "^3.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "14.2.5",
|
"version": "14.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz",
|
||||||
@@ -3035,6 +3047,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz",
|
||||||
"integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg=="
|
"integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@simbathesailor/use-what-changed": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simbathesailor/use-what-changed/-/use-what-changed-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ulBNrPSvfho9UN6zS2fii3AsdEcp2fMaKeqUZZeCNPaZbB6aXyTUhpEN9atjMAbu/eyK3AY8L4SYJUG62Ekocw==",
|
||||||
|
"dev": true,
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@swc/counter": {
|
"node_modules/@swc/counter": {
|
||||||
"version": "0.1.3",
|
"version": "0.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||||
@@ -3454,6 +3475,19 @@
|
|||||||
"@types/debounce": "*"
|
"@types/debounce": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/webidl-conversions": {
|
||||||
|
"version": "7.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
|
||||||
|
"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="
|
||||||
|
},
|
||||||
|
"node_modules/@types/whatwg-url": {
|
||||||
|
"version": "11.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz",
|
||||||
|
"integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/webidl-conversions": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/parser": {
|
"node_modules/@typescript-eslint/parser": {
|
||||||
"version": "5.51.0",
|
"version": "5.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.51.0.tgz",
|
||||||
@@ -4000,6 +4034,25 @@
|
|||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/axios-cache-interceptor": {
|
||||||
|
"version": "1.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios-cache-interceptor/-/axios-cache-interceptor-1.5.3.tgz",
|
||||||
|
"integrity": "sha512-kPgGId9XW7tR+VF7hgSkqF4f6FrV4ecCyKxjkD9v1hNJ4sXSAskocr7SMKaVHVvrbzVeruwB6yL6Y9/lY1ApKg==",
|
||||||
|
"dependencies": {
|
||||||
|
"cache-parser": "1.2.5",
|
||||||
|
"fast-defer": "1.1.8",
|
||||||
|
"object-code": "1.3.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/arthurfiorette/axios-cache-interceptor?sponsor=1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"axios": "^1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/axobject-query": {
|
"node_modules/axobject-query": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz",
|
||||||
@@ -4056,6 +4109,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",
|
||||||
@@ -4240,6 +4307,14 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bson": {
|
||||||
|
"version": "6.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bson/-/bson-6.8.0.tgz",
|
||||||
|
"integrity": "sha512-iOJg8pr7wq2tg/zSlCCHMi3hMm5JTOxLTagf3zxhcenHsFp+c6uOs6K7W5UE7A4QIJGtqh/ZovFNMP4mOPJynQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/buffer": {
|
"node_modules/buffer": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||||
@@ -4303,6 +4378,11 @@
|
|||||||
"node": ">=10.16.0"
|
"node": ">=10.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cache-parser": {
|
||||||
|
"version": "1.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/cache-parser/-/cache-parser-1.2.5.tgz",
|
||||||
|
"integrity": "sha512-Md/4VhAHByQ9frQ15WD6LrMNiVw9AEl/J7vWIXw+sxT6fSOpbtt6LHTp76vy8+bOESPBO94117Hm2bIjlI7XjA=="
|
||||||
|
},
|
||||||
"node_modules/call-bind": {
|
"node_modules/call-bind": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
|
||||||
@@ -4619,6 +4699,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",
|
||||||
@@ -6023,6 +6110,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-defer": {
|
||||||
|
"version": "1.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-defer/-/fast-defer-1.1.8.tgz",
|
||||||
|
"integrity": "sha512-lEJeOH5VL5R09j6AA0D4Uvq7AgsHw0dAImQQ+F3iSyHZuAxyQfWobsagGpTcOPvJr3urmKRHrs+Gs9hV+/Qm/Q=="
|
||||||
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.2.12",
|
"version": "3.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
|
||||||
@@ -6224,6 +6316,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",
|
||||||
@@ -8339,6 +8442,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||||
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
|
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
|
||||||
},
|
},
|
||||||
|
"node_modules/memory-pager": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="
|
||||||
|
},
|
||||||
"node_modules/merge2": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@@ -8470,6 +8578,91 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mongodb": {
|
||||||
|
"version": "6.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.8.1.tgz",
|
||||||
|
"integrity": "sha512-qsS+gl5EJb+VzJqUjXSZ5Y5rbuM/GZlZUEJ2OIVYP10L9rO9DQ0DGp+ceTzsmoADh6QYMWd9MSdG9IxRyYUkEA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@mongodb-js/saslprep": "^1.1.5",
|
||||||
|
"bson": "^6.7.0",
|
||||||
|
"mongodb-connection-string-url": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@aws-sdk/credential-providers": "^3.188.0",
|
||||||
|
"@mongodb-js/zstd": "^1.1.0",
|
||||||
|
"gcp-metadata": "^5.2.0",
|
||||||
|
"kerberos": "^2.0.1",
|
||||||
|
"mongodb-client-encryption": ">=6.0.0 <7",
|
||||||
|
"snappy": "^7.2.2",
|
||||||
|
"socks": "^2.7.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@aws-sdk/credential-providers": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@mongodb-js/zstd": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"gcp-metadata": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"kerberos": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"mongodb-client-encryption": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"snappy": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"socks": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mongodb-connection-string-url": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/whatwg-url": "^11.0.2",
|
||||||
|
"whatwg-url": "^13.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mongodb-connection-string-url/node_modules/tr46": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==",
|
||||||
|
"dependencies": {
|
||||||
|
"punycode": "^2.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mongodb-connection-string-url/node_modules/webidl-conversions": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mongodb-connection-string-url/node_modules/whatwg-url": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "^4.1.1",
|
||||||
|
"webidl-conversions": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||||
@@ -8697,6 +8890,11 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-code": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-code/-/object-code-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-/Ds4Xd5xzrtUOJ+xJQ57iAy0BZsZltOHssnDgcZ8DOhgh41q1YJCnTPnWdWSLkNGNnxYzhYChjc5dgC9mEERCA=="
|
||||||
|
},
|
||||||
"node_modules/object-hash": {
|
"node_modules/object-hash": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||||
@@ -9635,9 +9833,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": "*"
|
||||||
}
|
}
|
||||||
@@ -10348,6 +10546,14 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sparse-bitfield": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"memory-pager": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stop-iteration-iterator": {
|
"node_modules/stop-iteration-iterator": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz",
|
||||||
@@ -10632,10 +10838,11 @@
|
|||||||
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="
|
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="
|
||||||
},
|
},
|
||||||
"node_modules/swr": {
|
"node_modules/swr": {
|
||||||
"version": "2.1.3",
|
"version": "2.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/swr/-/swr-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/swr/-/swr-2.2.5.tgz",
|
||||||
"integrity": "sha512-g3ApxIM4Fjbd6vvEAlW60hJlKcYxHb+wtehogTygrh6Jsw7wNagv9m4Oj5Gq6zvvZw0tcyhVGL9L0oISvl3sUw==",
|
"integrity": "sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"client-only": "^0.0.1",
|
||||||
"use-sync-external-store": "^1.2.0"
|
"use-sync-external-store": "^1.2.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -13130,6 +13337,14 @@
|
|||||||
"prop-types": "^15.7.2"
|
"prop-types": "^15.7.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@mongodb-js/saslprep": {
|
||||||
|
"version": "1.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz",
|
||||||
|
"integrity": "sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==",
|
||||||
|
"requires": {
|
||||||
|
"sparse-bitfield": "^3.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@next/env": {
|
"@next/env": {
|
||||||
"version": "14.2.5",
|
"version": "14.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz",
|
||||||
@@ -13868,6 +14083,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz",
|
||||||
"integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg=="
|
"integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg=="
|
||||||
},
|
},
|
||||||
|
"@simbathesailor/use-what-changed": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simbathesailor/use-what-changed/-/use-what-changed-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ulBNrPSvfho9UN6zS2fii3AsdEcp2fMaKeqUZZeCNPaZbB6aXyTUhpEN9atjMAbu/eyK3AY8L4SYJUG62Ekocw==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"@swc/counter": {
|
"@swc/counter": {
|
||||||
"version": "0.1.3",
|
"version": "0.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||||
@@ -14254,6 +14475,19 @@
|
|||||||
"@types/debounce": "*"
|
"@types/debounce": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@types/webidl-conversions": {
|
||||||
|
"version": "7.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
|
||||||
|
"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="
|
||||||
|
},
|
||||||
|
"@types/whatwg-url": {
|
||||||
|
"version": "11.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz",
|
||||||
|
"integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
|
||||||
|
"requires": {
|
||||||
|
"@types/webidl-conversions": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@typescript-eslint/parser": {
|
"@typescript-eslint/parser": {
|
||||||
"version": "5.51.0",
|
"version": "5.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.51.0.tgz",
|
||||||
@@ -14645,6 +14879,16 @@
|
|||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"axios-cache-interceptor": {
|
||||||
|
"version": "1.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios-cache-interceptor/-/axios-cache-interceptor-1.5.3.tgz",
|
||||||
|
"integrity": "sha512-kPgGId9XW7tR+VF7hgSkqF4f6FrV4ecCyKxjkD9v1hNJ4sXSAskocr7SMKaVHVvrbzVeruwB6yL6Y9/lY1ApKg==",
|
||||||
|
"requires": {
|
||||||
|
"cache-parser": "1.2.5",
|
||||||
|
"fast-defer": "1.1.8",
|
||||||
|
"object-code": "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"axobject-query": {
|
"axobject-query": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz",
|
||||||
@@ -14697,6 +14941,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",
|
||||||
@@ -14820,6 +15080,11 @@
|
|||||||
"update-browserslist-db": "^1.0.10"
|
"update-browserslist-db": "^1.0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"bson": {
|
||||||
|
"version": "6.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bson/-/bson-6.8.0.tgz",
|
||||||
|
"integrity": "sha512-iOJg8pr7wq2tg/zSlCCHMi3hMm5JTOxLTagf3zxhcenHsFp+c6uOs6K7W5UE7A4QIJGtqh/ZovFNMP4mOPJynQ=="
|
||||||
|
},
|
||||||
"buffer": {
|
"buffer": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||||
@@ -14857,6 +15122,11 @@
|
|||||||
"streamsearch": "^1.1.0"
|
"streamsearch": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"cache-parser": {
|
||||||
|
"version": "1.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/cache-parser/-/cache-parser-1.2.5.tgz",
|
||||||
|
"integrity": "sha512-Md/4VhAHByQ9frQ15WD6LrMNiVw9AEl/J7vWIXw+sxT6fSOpbtt6LHTp76vy8+bOESPBO94117Hm2bIjlI7XjA=="
|
||||||
|
},
|
||||||
"call-bind": {
|
"call-bind": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
|
||||||
@@ -15083,6 +15353,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",
|
||||||
@@ -16181,6 +16456,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||||
},
|
},
|
||||||
|
"fast-defer": {
|
||||||
|
"version": "1.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-defer/-/fast-defer-1.1.8.tgz",
|
||||||
|
"integrity": "sha512-lEJeOH5VL5R09j6AA0D4Uvq7AgsHw0dAImQQ+F3iSyHZuAxyQfWobsagGpTcOPvJr3urmKRHrs+Gs9hV+/Qm/Q=="
|
||||||
|
},
|
||||||
"fast-glob": {
|
"fast-glob": {
|
||||||
"version": "3.2.12",
|
"version": "3.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
|
||||||
@@ -16352,6 +16632,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",
|
||||||
@@ -17947,6 +18235,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||||
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
|
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
|
||||||
},
|
},
|
||||||
|
"memory-pager": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="
|
||||||
|
},
|
||||||
"merge2": {
|
"merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@@ -18035,6 +18328,49 @@
|
|||||||
"moment": "^2.29.4"
|
"moment": "^2.29.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"mongodb": {
|
||||||
|
"version": "6.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.8.1.tgz",
|
||||||
|
"integrity": "sha512-qsS+gl5EJb+VzJqUjXSZ5Y5rbuM/GZlZUEJ2OIVYP10L9rO9DQ0DGp+ceTzsmoADh6QYMWd9MSdG9IxRyYUkEA==",
|
||||||
|
"requires": {
|
||||||
|
"@mongodb-js/saslprep": "^1.1.5",
|
||||||
|
"bson": "^6.7.0",
|
||||||
|
"mongodb-connection-string-url": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mongodb-connection-string-url": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==",
|
||||||
|
"requires": {
|
||||||
|
"@types/whatwg-url": "^11.0.2",
|
||||||
|
"whatwg-url": "^13.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==",
|
||||||
|
"requires": {
|
||||||
|
"punycode": "^2.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"webidl-conversions": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="
|
||||||
|
},
|
||||||
|
"whatwg-url": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==",
|
||||||
|
"requires": {
|
||||||
|
"tr46": "^4.1.1",
|
||||||
|
"webidl-conversions": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ms": {
|
"ms": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||||
@@ -18186,6 +18522,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="
|
||||||
},
|
},
|
||||||
|
"object-code": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-code/-/object-code-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-/Ds4Xd5xzrtUOJ+xJQ57iAy0BZsZltOHssnDgcZ8DOhgh41q1YJCnTPnWdWSLkNGNnxYzhYChjc5dgC9mEERCA=="
|
||||||
|
},
|
||||||
"object-hash": {
|
"object-hash": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||||
@@ -18839,9 +19180,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",
|
||||||
@@ -19351,6 +19692,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
|
||||||
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw=="
|
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw=="
|
||||||
},
|
},
|
||||||
|
"sparse-bitfield": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
|
||||||
|
"requires": {
|
||||||
|
"memory-pager": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"stop-iteration-iterator": {
|
"stop-iteration-iterator": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz",
|
||||||
@@ -19564,10 +19913,11 @@
|
|||||||
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="
|
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="
|
||||||
},
|
},
|
||||||
"swr": {
|
"swr": {
|
||||||
"version": "2.1.3",
|
"version": "2.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/swr/-/swr-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/swr/-/swr-2.2.5.tgz",
|
||||||
"integrity": "sha512-g3ApxIM4Fjbd6vvEAlW60hJlKcYxHb+wtehogTygrh6Jsw7wNagv9m4Oj5Gq6zvvZw0tcyhVGL9L0oISvl3sUw==",
|
"integrity": "sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==",
|
||||||
"requires": {
|
"requires": {
|
||||||
|
"client-only": "^0.0.1",
|
||||||
"use-sync-external-store": "^1.2.0"
|
"use-sync-external-store": "^1.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -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",
|
||||||
@@ -51,6 +53,7 @@
|
|||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"moment-timezone": "^0.5.44",
|
"moment-timezone": "^0.5.44",
|
||||||
|
"mongodb": "^6.8.1",
|
||||||
"next": "^14.2.5",
|
"next": "^14.2.5",
|
||||||
"nodemailer": "^6.9.5",
|
"nodemailer": "^6.9.5",
|
||||||
"nodemailer-express-handlebars": "^6.1.0",
|
"nodemailer-express-handlebars": "^6.1.0",
|
||||||
@@ -66,7 +69,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 +82,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 +93,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,51 +1,58 @@
|
|||||||
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,
|
||||||
type,
|
type,
|
||||||
prompt,
|
prompt,
|
||||||
solutions,
|
solutions,
|
||||||
text,
|
text,
|
||||||
words,
|
words,
|
||||||
userSolutions,
|
userSolutions,
|
||||||
variant,
|
variant,
|
||||||
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 shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [currentMCSelection, setCurrentMCSelection] = useState<{ id: string, selection: FillBlanksMCOption }>();
|
const excludeWordMCType = (x: any) => {
|
||||||
|
return typeof x === "string" ? x : (x as { letter: string; word: string });
|
||||||
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) => {
|
|
||||||
return typeof x === "string" ? x : x as { letter: string; word: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type });
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [hasExamEnded]);
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type });
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [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;
|
||||||
@@ -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,179 +72,168 @@ 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;
|
||||||
}).length;
|
}).length;
|
||||||
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) => {
|
|
||||||
return (
|
|
||||||
<div className="text-base leading-5">
|
|
||||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
|
||||||
const id = match.replaceAll(/[\{\}]/g, "");
|
|
||||||
const userSolution = answers.find((x) => x.id === id);
|
|
||||||
const styles = clsx(
|
|
||||||
"rounded-full hover:text-white 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",
|
|
||||||
!userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
|
|
||||||
userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight",
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
variant === "mc" ? (
|
|
||||||
<>
|
|
||||||
{/*<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 false;
|
|
||||||
}) as FillBlanksMCOption
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{userSolution?.solution === undefined ? <span className="text-transparent select-none">placeholder</span> : <span> {userSolution.solution} </span>}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
className={styles}
|
|
||||||
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])}
|
|
||||||
value={userSolution?.solution} />
|
|
||||||
)
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSelection = (id: string, value: string) => {
|
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
|
||||||
setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id: id, solution: value }]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getShuffles = () => {
|
const renderLines = useCallback(
|
||||||
let shuffle = {};
|
(line: string) => {
|
||||||
if (shuffleMaps.length !== 0) {
|
return (
|
||||||
shuffle = {
|
<div className="text-xl leading-5" key={v4()} ref={dropdownRef}>
|
||||||
shuffleMaps: shuffleMaps.filter((map) =>
|
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||||
answers.some(answer => answer.id === map.id)
|
const id = match.replaceAll(/[\{\}]/g, "");
|
||||||
)
|
const userSolution = answers.find((x) => x.id === id);
|
||||||
}
|
const styles = clsx(
|
||||||
}
|
"rounded-full hover:text-white transition duration-300 ease-in-out my-1 px-5 py-2 text-center w-fit",
|
||||||
return shuffle;
|
!userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
|
||||||
}
|
userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight",
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
const currentSelection = words.find((x) => {
|
||||||
<>
|
if (typeof x !== "string" && "id" in x) {
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
return (x as FillBlanksMCOption).id.toString() == id.toString();
|
||||||
{false && <span className="text-sm w-full leading-6">
|
}
|
||||||
{prompt.split("\\n").map((line, index) => (
|
return false;
|
||||||
<Fragment key={index}>
|
}) as FillBlanksMCOption;
|
||||||
{line}
|
|
||||||
<br />
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</span>}
|
|
||||||
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
|
||||||
{text.split("\\n").map((line, index) => (
|
|
||||||
<p key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}>
|
|
||||||
{renderLines(line)}
|
|
||||||
<br />
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</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
|
return variant === "mc" ? (
|
||||||
className={clsx(
|
<MCDropdown
|
||||||
"border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300",
|
id={id}
|
||||||
!!answers.find((x) => x.solution.toLocaleLowerCase() === value.toLocaleLowerCase() && x.id === currentMCSelection.id) &&
|
options={currentSelection.options}
|
||||||
"bg-mti-purple-dark text-white",
|
onSelect={(value) => onSelection(id, value)}
|
||||||
)}
|
selectedValue={userSolution?.solution}
|
||||||
key={v4()}
|
className="inline-block py-2 px-1"
|
||||||
onClick={() => onSelection(currentMCSelection.id, value)}
|
width={220}
|
||||||
>
|
isOpen={openDropdownId === id}
|
||||||
{value}
|
onToggle={()=> setOpenDropdownId(prevId => prevId === id ? null : id)}
|
||||||
</button>;*/
|
/>
|
||||||
})}
|
) : (
|
||||||
</div>
|
<input
|
||||||
</div>
|
className={styles}
|
||||||
)}
|
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])}
|
||||||
</>
|
value={userSolution?.solution}
|
||||||
) : (
|
/>
|
||||||
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-4">
|
);
|
||||||
<span className="font-medium text-mti-purple-dark">Options</span>
|
})
|
||||||
<div className="flex gap-4 flex-wrap">
|
}
|
||||||
{words.map((v) => {
|
</div >
|
||||||
v = excludeWordMCType(v);
|
);
|
||||||
const text = typeof v === "string" ? v : `${v.letter} - ${v.word}`;
|
},
|
||||||
|
[variant, words, answers, openDropdownId],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
const memoizedLines = useMemo(() => {
|
||||||
<span
|
return text.split("\\n").map((line, index) => (
|
||||||
className={clsx(
|
<p key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}>
|
||||||
"border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300",
|
{renderLines(line)}
|
||||||
!!answers.find((x) => x.solution.toLowerCase() === (typeof v === "string" ? v : ("letter" in v ? v.letter : "")).toLowerCase()) &&
|
<br />
|
||||||
"bg-mti-purple-dark text-white",
|
</p>
|
||||||
)}
|
));
|
||||||
key={v4()}
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
>
|
}, [text, variant, renderLines]);
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div >
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() })}
|
|
||||||
className="max-w-[200px] w-full"
|
|
||||||
disabled={
|
|
||||||
exam && typeof partIndex !== "undefined" && exam.module === "level" &&
|
|
||||||
typeof exam.parts[0].intro === "string" && questionIndex === 0}
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
const onSelection = (questionID: string, value: string) => {
|
||||||
color="purple"
|
setAnswers((prev) => [...prev.filter((x) => x.id !== questionID), { id: questionID, solution: value }]);
|
||||||
onClick={() => onNext({ exercise: id, solutions: answers, score: calculateScore(), type, ...getShuffles() })}
|
};
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
useEffect(() => {
|
||||||
</Button>
|
if (variant === "mc") {
|
||||||
</div>
|
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
||||||
</>
|
}
|
||||||
);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}
|
}, [answers]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps })}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
||||||
|
Previous Page
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => {
|
||||||
|
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
||||||
|
}}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
|
{variant !== "mc" && (
|
||||||
|
<span className="text-sm w-full leading-6">
|
||||||
|
{prompt.split("\\n").map((line, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">{memoizedLines}</span>
|
||||||
|
{variant !== "mc" && (
|
||||||
|
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-4">
|
||||||
|
<span className="font-medium text-mti-purple-dark">Options</span>
|
||||||
|
<div className="flex gap-4 flex-wrap">
|
||||||
|
{words.map((v) => {
|
||||||
|
v = excludeWordMCType(v);
|
||||||
|
const text = typeof v === "string" ? v : `${v.letter} - ${v.word}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300",
|
||||||
|
!!answers.find(
|
||||||
|
(x) =>
|
||||||
|
x.solution.toLowerCase() ===
|
||||||
|
(typeof v === "string" ? v : "letter" in v ? v.letter : "").toLowerCase(),
|
||||||
|
) && "bg-mti-purple-dark text-white",
|
||||||
|
)}
|
||||||
|
key={v4()}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps })}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
||||||
|
Previous Page
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => {
|
||||||
|
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
||||||
|
}}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default FillBlanks;
|
export default FillBlanks;
|
||||||
|
|||||||
@@ -152,139 +152,8 @@ export default function InteractiveSpeaking({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full gap-9">
|
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||||
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
<div className="flex justify-between w-full gap-8">
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<span className="font-semibold">{!!first_title && !!second_title ? `${first_title} & ${second_title}` : title}</span>
|
|
||||||
</div>
|
|
||||||
{prompts && prompts.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-4 w-full items-center">
|
|
||||||
<video key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
|
|
||||||
<source src={prompts[questionIndex].video_url} />
|
|
||||||
</video>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ReactMediaRecorder
|
|
||||||
audio
|
|
||||||
key={questionIndex}
|
|
||||||
onStop={(blob) => setMediaBlob(blob)}
|
|
||||||
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
|
|
||||||
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
|
||||||
<p className="text-base font-normal">Record your answer:</p>
|
|
||||||
<div className="flex gap-8 items-center justify-center py-8">
|
|
||||||
{status === "idle" && (
|
|
||||||
<>
|
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
|
||||||
{status === "idle" && (
|
|
||||||
<BsMicFill
|
|
||||||
onClick={() => {
|
|
||||||
setRecordingDuration(0);
|
|
||||||
startRecording();
|
|
||||||
setIsRecording(true);
|
|
||||||
}}
|
|
||||||
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{status === "recording" && (
|
|
||||||
<>
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<span className="text-xs w-9">
|
|
||||||
{Math.floor(recordingDuration / 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
:
|
|
||||||
{Math.floor(recordingDuration % 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<BsPauseCircle
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(false);
|
|
||||||
pauseRecording();
|
|
||||||
}}
|
|
||||||
className="text-red-500 w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<BsCheckCircleFill
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(false);
|
|
||||||
stopRecording();
|
|
||||||
}}
|
|
||||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{status === "paused" && (
|
|
||||||
<>
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<span className="text-xs w-9">
|
|
||||||
{Math.floor(recordingDuration / 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
:
|
|
||||||
{Math.floor(recordingDuration % 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<BsPlayCircle
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(true);
|
|
||||||
resumeRecording();
|
|
||||||
}}
|
|
||||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<BsCheckCircleFill
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(false);
|
|
||||||
stopRecording();
|
|
||||||
}}
|
|
||||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{status === "stopped" && mediaBlobUrl && (
|
|
||||||
<>
|
|
||||||
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<BsTrashFill
|
|
||||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
|
||||||
onClick={() => {
|
|
||||||
setRecordingDuration(0);
|
|
||||||
clearBlobUrl();
|
|
||||||
setMediaBlob(undefined);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BsMicFill
|
|
||||||
onClick={() => {
|
|
||||||
clearBlobUrl();
|
|
||||||
setRecordingDuration(0);
|
|
||||||
startRecording();
|
|
||||||
setIsRecording(true);
|
|
||||||
setMediaBlob(undefined);
|
|
||||||
}}
|
|
||||||
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8">
|
|
||||||
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
@@ -292,6 +161,148 @@ export default function InteractiveSpeaking({
|
|||||||
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col h-full w-full gap-9">
|
||||||
|
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<span className="font-semibold">{!!first_title && !!second_title ? `${first_title} & ${second_title}` : title}</span>
|
||||||
|
</div>
|
||||||
|
{prompts && prompts.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-4 w-full items-center">
|
||||||
|
<video key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
|
||||||
|
<source src={prompts[questionIndex].video_url} />
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReactMediaRecorder
|
||||||
|
audio
|
||||||
|
key={questionIndex}
|
||||||
|
onStop={(blob) => setMediaBlob(blob)}
|
||||||
|
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
|
||||||
|
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||||
|
<p className="text-base font-normal">Record your answer:</p>
|
||||||
|
<div className="flex gap-8 items-center justify-center py-8">
|
||||||
|
{status === "idle" && (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
{status === "idle" && (
|
||||||
|
<BsMicFill
|
||||||
|
onClick={() => {
|
||||||
|
setRecordingDuration(0);
|
||||||
|
startRecording();
|
||||||
|
setIsRecording(true);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "recording" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<span className="text-xs w-9">
|
||||||
|
{Math.floor(recordingDuration / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(recordingDuration % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsPauseCircle
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
pauseRecording();
|
||||||
|
}}
|
||||||
|
className="text-red-500 w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<BsCheckCircleFill
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
stopRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "paused" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<span className="text-xs w-9">
|
||||||
|
{Math.floor(recordingDuration / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(recordingDuration % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsPlayCircle
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(true);
|
||||||
|
resumeRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<BsCheckCircleFill
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
stopRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "stopped" && mediaBlobUrl && (
|
||||||
|
<>
|
||||||
|
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsTrashFill
|
||||||
|
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||||
|
onClick={() => {
|
||||||
|
setRecordingDuration(0);
|
||||||
|
clearBlobUrl();
|
||||||
|
setMediaBlob(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BsMicFill
|
||||||
|
onClick={() => {
|
||||||
|
clearBlobUrl();
|
||||||
|
setRecordingDuration(0);
|
||||||
|
startRecording();
|
||||||
|
setIsRecording(true);
|
||||||
|
setMediaBlob(undefined);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="self-end flex justify-between w-full gap-8">
|
||||||
|
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
|
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,12 +1,12 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import { MultipleChoiceExercise, MultipleChoiceQuestion } from "@/interfaces/exam";
|
import {MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap} from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useEffect, useState } from "react";
|
import {useEffect, useState} from "react";
|
||||||
import reactStringReplace from "react-string-replace";
|
import 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";
|
||||||
|
|
||||||
function Question({
|
function Question({
|
||||||
id,
|
id,
|
||||||
@@ -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>
|
||||||
@@ -71,37 +72,38 @@ 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(() => {
|
useEffect(() => {
|
||||||
setUserSolutions(
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
[...storeUserSolutions.filter((x) => x.exercise !== id), {
|
|
||||||
exercise: id, solutions: answers, score: calculateScore(), type
|
|
||||||
}]);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [answers]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
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 = () => {
|
||||||
@@ -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">
|
||||||
{/*<span className="text-xl font-semibold mb-2">{"Select the appropriate option."}</span>*/}
|
<Button
|
||||||
{questionIndex < questions.length && (
|
color="purple"
|
||||||
<Question
|
variant="outline"
|
||||||
{...questions[questionIndex]}
|
onClick={back}
|
||||||
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
|
className="max-w-[200px] w-full"
|
||||||
onSelectOption={onSelectOption}
|
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
|
||||||
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full"
|
|
||||||
disabled={
|
|
||||||
exam && exam.module === "level" && typeof exam.parts[0].intro === "string" && 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 className="flex flex-col gap-4 mt-4 mb-20">
|
||||||
|
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
|
{/*<span className="text-xl font-semibold mb-2">{"Select the appropriate option."}</span>*/}
|
||||||
|
{questionIndex < questions.length && (
|
||||||
|
<Question
|
||||||
|
{...questions[questionIndex]}
|
||||||
|
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
|
||||||
|
onSelectOption={(option) => onSelectOption(option, questions[questionIndex])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{questionIndex + 1 < questions.length && (
|
||||||
|
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
|
<Question
|
||||||
|
{...questions[questionIndex + 1]}
|
||||||
|
userSolution={answers.find((x) => questions[questionIndex + 1].id === x.question)?.option}
|
||||||
|
onSelectOption={(option) => onSelectOption(option, questions[questionIndex + 1])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={back}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
|
{exam &&
|
||||||
|
exam.module === "level" &&
|
||||||
|
partIndex === exam.parts.length - 1 &&
|
||||||
|
exerciseIndex === exam.parts[partIndex].exercises.length - 1 &&
|
||||||
|
questionIndex + 1 >= questions.length - 1
|
||||||
|
? "Submit"
|
||||||
|
: "Next"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { SpeakingExercise } from "@/interfaces/exam";
|
import {SpeakingExercise} from "@/interfaces/exam";
|
||||||
import { CommonProps } from ".";
|
import {CommonProps} from ".";
|
||||||
import { Fragment, useEffect, useState } from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs";
|
import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import { downloadBlob } from "@/utils/evaluation";
|
import {downloadBlob} from "@/utils/evaluation";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import Modal from "../Modal";
|
import Modal from "../Modal";
|
||||||
|
|
||||||
const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
|
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||||
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function Speaking({ id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack }: SpeakingExercise & CommonProps) {
|
export default function Speaking({id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||||
@@ -28,7 +28,7 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
const saveToStorage = async () => {
|
const saveToStorage = async () => {
|
||||||
if (mediaBlob && mediaBlob.startsWith("blob")) {
|
if (mediaBlob && mediaBlob.startsWith("blob")) {
|
||||||
const blobBuffer = await downloadBlob(mediaBlob);
|
const blobBuffer = await downloadBlob(mediaBlob);
|
||||||
const audioFile = new File([blobBuffer], "audio.wav", { type: "audio/wav" });
|
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
|
||||||
|
|
||||||
const seed = Math.random().toString().replace("0.", "");
|
const seed = Math.random().toString().replace("0.", "");
|
||||||
|
|
||||||
@@ -42,8 +42,8 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await axios.post<{ path: string }>("/api/storage/insert", formData, config);
|
const response = await axios.post<{path: string}>("/api/storage/insert", formData, config);
|
||||||
if (audioURL) await axios.post("/api/storage/delete", { path: audioURL });
|
if (audioURL) await axios.post("/api/storage/delete", {path: audioURL});
|
||||||
return response.data.path;
|
return response.data.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userSolutions.length > 0) {
|
if (userSolutions.length > 0) {
|
||||||
const { solution } = userSolutions[0] as { solution?: string };
|
const {solution} = userSolutions[0] as {solution?: string};
|
||||||
if (solution && !mediaBlob) setMediaBlob(solution);
|
if (solution && !mediaBlob) setMediaBlob(solution);
|
||||||
if (solution && !solution.startsWith("blob")) setAudioURL(solution);
|
if (solution && !solution.startsWith("blob")) setAudioURL(solution);
|
||||||
}
|
}
|
||||||
@@ -79,8 +79,8 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
const next = async () => {
|
const next = async () => {
|
||||||
onNext({
|
onNext({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
|
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||||
score: { correct: 0, total: 100, missing: 0 },
|
score: {correct: 0, total: 100, missing: 0},
|
||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -88,8 +88,8 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
const back = async () => {
|
const back = async () => {
|
||||||
onBack({
|
onBack({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
|
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||||
score: { correct: 0, total: 100, missing: 0 },
|
score: {correct: 0, total: 100, missing: 0},
|
||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -98,7 +98,7 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
const newText = e.target.value;
|
const newText = e.target.value;
|
||||||
const words = newText.match(/\S+/g);
|
const words = newText.match(/\S+/g);
|
||||||
const wordCount = words ? words.length : 0;
|
const wordCount = words ? words.length : 0;
|
||||||
|
|
||||||
if (wordCount <= 100) {
|
if (wordCount <= 100) {
|
||||||
setInputText(newText);
|
setInputText(newText);
|
||||||
} else {
|
} else {
|
||||||
@@ -110,188 +110,14 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
if (count > 100) break;
|
if (count > 100) break;
|
||||||
lastIndex = match.index! + match[0].length;
|
lastIndex = match.index! + match[0].length;
|
||||||
}
|
}
|
||||||
|
|
||||||
setInputText(newText.slice(0, lastIndex));
|
setInputText(newText.slice(0, lastIndex));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full gap-9">
|
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||||
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
|
<div className="flex justify-between w-full gap-8">
|
||||||
<div className="flex flex-col items-center justify-center gap-4 w-full h-full">
|
|
||||||
<div className="flex flex-col gap-1 ml-4">
|
|
||||||
{prompts.map((x, index) => (
|
|
||||||
<li className="italic" key={index}>
|
|
||||||
{x}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{!!suffix && <span className="font-bold">{suffix}</span>}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex flex-col gap-0">
|
|
||||||
<span className="font-semibold">{title}</span>
|
|
||||||
{prompts.length > 0 && (
|
|
||||||
<span className="font-semibold">You should talk for at least 1 minute and 30 seconds for your answer to be valid.</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!video_url && (
|
|
||||||
<span className="font-regular">
|
|
||||||
{text.split("\\n").map((line, index) => (
|
|
||||||
<Fragment key={index}>
|
|
||||||
<span>{line}</span>
|
|
||||||
<br />
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-6 items-center">
|
|
||||||
{video_url && (
|
|
||||||
<div className="flex flex-col gap-4 w-full items-center">
|
|
||||||
<video key={id} autoPlay controls className="max-w-3xl rounded-xl">
|
|
||||||
<source src={video_url} />
|
|
||||||
</video>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{prompts && prompts.length > 0 && <Button onClick={() => setIsPromptsModalOpen(true)}>View Prompts</Button>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{prompts && prompts.length > 0 && (
|
|
||||||
<div className="w-full h-full flex flex-col gap-4">
|
|
||||||
<textarea
|
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
|
||||||
className="w-full h-full min-h-[200px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
|
||||||
onChange={handleNoteWriting}
|
|
||||||
value={inputText}
|
|
||||||
placeholder="Write your notes here..."
|
|
||||||
spellCheck={false}
|
|
||||||
/>
|
|
||||||
<span className="text-base self-end text-mti-gray-cool">Word Count: {(inputText.match(/\S+/g) || []).length}/100</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ReactMediaRecorder
|
|
||||||
audio
|
|
||||||
onStop={(blob) => setMediaBlob(blob)}
|
|
||||||
render={({ status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl }) => (
|
|
||||||
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
|
||||||
<p className="text-base font-normal">Record your answer:</p>
|
|
||||||
<div className="flex gap-8 items-center justify-center py-8">
|
|
||||||
{status === "idle" && !mediaBlob && (
|
|
||||||
<>
|
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
|
||||||
{status === "idle" && (
|
|
||||||
<BsMicFill
|
|
||||||
onClick={() => {
|
|
||||||
setRecordingDuration(0);
|
|
||||||
startRecording();
|
|
||||||
setIsRecording(true);
|
|
||||||
}}
|
|
||||||
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{status === "recording" && (
|
|
||||||
<>
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<span className="text-xs w-9">
|
|
||||||
{Math.floor(recordingDuration / 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
:
|
|
||||||
{Math.floor(recordingDuration % 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<BsPauseCircle
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(false);
|
|
||||||
pauseRecording();
|
|
||||||
}}
|
|
||||||
className="text-red-500 w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<BsCheckCircleFill
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(false);
|
|
||||||
stopRecording();
|
|
||||||
}}
|
|
||||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{status === "paused" && (
|
|
||||||
<>
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<span className="text-xs w-9">
|
|
||||||
{Math.floor(recordingDuration / 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
:
|
|
||||||
{Math.floor(recordingDuration % 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<BsPlayCircle
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(true);
|
|
||||||
resumeRecording();
|
|
||||||
}}
|
|
||||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<BsCheckCircleFill
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(false);
|
|
||||||
stopRecording();
|
|
||||||
}}
|
|
||||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
|
|
||||||
<>
|
|
||||||
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<BsTrashFill
|
|
||||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
|
||||||
onClick={() => {
|
|
||||||
setRecordingDuration(0);
|
|
||||||
clearBlobUrl();
|
|
||||||
setMediaBlob(undefined);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BsMicFill
|
|
||||||
onClick={() => {
|
|
||||||
clearBlobUrl();
|
|
||||||
setRecordingDuration(0);
|
|
||||||
startRecording();
|
|
||||||
setIsRecording(true);
|
|
||||||
setMediaBlob(undefined);
|
|
||||||
}}
|
|
||||||
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8">
|
|
||||||
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
@@ -299,6 +125,193 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col h-full w-full gap-9">
|
||||||
|
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 w-full h-full">
|
||||||
|
<div className="flex flex-col gap-1 ml-4">
|
||||||
|
{prompts.map((x, index) => (
|
||||||
|
<li className="italic" key={index}>
|
||||||
|
{x}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!!suffix && <span className="font-bold">{suffix}</span>}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-0">
|
||||||
|
<span className="font-semibold">{title}</span>
|
||||||
|
{prompts.length > 0 && (
|
||||||
|
<span className="font-semibold">
|
||||||
|
You should talk for at least 1 minute and 30 seconds for your answer to be valid.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!video_url && (
|
||||||
|
<span className="font-regular">
|
||||||
|
{text.split("\\n").map((line, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<span>{line}</span>
|
||||||
|
<br />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-6 items-center">
|
||||||
|
{video_url && (
|
||||||
|
<div className="flex flex-col gap-4 w-full items-center">
|
||||||
|
<video key={id} autoPlay controls className="max-w-3xl rounded-xl">
|
||||||
|
<source src={video_url} />
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{prompts && prompts.length > 0 && <Button onClick={() => setIsPromptsModalOpen(true)}>View Prompts</Button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{prompts && prompts.length > 0 && (
|
||||||
|
<div className="w-full h-full flex flex-col gap-4">
|
||||||
|
<textarea
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
className="w-full h-full min-h-[200px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
||||||
|
onChange={handleNoteWriting}
|
||||||
|
value={inputText}
|
||||||
|
placeholder="Write your notes here..."
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<span className="text-base self-end text-mti-gray-cool">Word Count: {(inputText.match(/\S+/g) || []).length}/100</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ReactMediaRecorder
|
||||||
|
audio
|
||||||
|
onStop={(blob) => setMediaBlob(blob)}
|
||||||
|
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
|
||||||
|
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||||
|
<p className="text-base font-normal">Record your answer:</p>
|
||||||
|
<div className="flex gap-8 items-center justify-center py-8">
|
||||||
|
{status === "idle" && !mediaBlob && (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
{status === "idle" && (
|
||||||
|
<BsMicFill
|
||||||
|
onClick={() => {
|
||||||
|
setRecordingDuration(0);
|
||||||
|
startRecording();
|
||||||
|
setIsRecording(true);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "recording" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<span className="text-xs w-9">
|
||||||
|
{Math.floor(recordingDuration / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(recordingDuration % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsPauseCircle
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
pauseRecording();
|
||||||
|
}}
|
||||||
|
className="text-red-500 w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<BsCheckCircleFill
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
stopRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "paused" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<span className="text-xs w-9">
|
||||||
|
{Math.floor(recordingDuration / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(recordingDuration % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsPlayCircle
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(true);
|
||||||
|
resumeRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<BsCheckCircleFill
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
stopRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
|
||||||
|
<>
|
||||||
|
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsTrashFill
|
||||||
|
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||||
|
onClick={() => {
|
||||||
|
setRecordingDuration(0);
|
||||||
|
clearBlobUrl();
|
||||||
|
setMediaBlob(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BsMicFill
|
||||||
|
onClick={() => {
|
||||||
|
clearBlobUrl();
|
||||||
|
setRecordingDuration(0);
|
||||||
|
startRecording();
|
||||||
|
setIsRecording(true);
|
||||||
|
setMediaBlob(undefined);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="self-end flex justify-between w-full gap-8">
|
||||||
|
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
const [answers, setAnswers] = useState<{id: string; solution: "true" | "false" | "not_given"}[]>(userSolutions);
|
const [answers, setAnswers] = useState<{id: string; solution: "true" | "false" | "not_given"}[]>(userSolutions);
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
@@ -28,6 +29,11 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [answers, setAnswers]);
|
||||||
|
|
||||||
const toggleAnswer = (solution: "true" | "false" | "not_given", questionId: string) => {
|
const toggleAnswer = (solution: "true" | "false" | "not_given", questionId: string) => {
|
||||||
const answer = answers.find((x) => x.id === questionId);
|
const answer = answers.find((x) => x.id === questionId);
|
||||||
if (answer && answer.solution === solution) {
|
if (answer && answer.solution === solution) {
|
||||||
@@ -39,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;
|
||||||
@@ -8,11 +9,16 @@ 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">
|
||||||
@@ -47,33 +53,75 @@ const FillBlanksEdit = (props: Props) => {
|
|||||||
value={solution.solution}
|
value={solution.solution}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
updateExercise({
|
updateExercise({
|
||||||
solutions: exercise.solutions.map((sol) => (sol.id === solution.id ? {...sol, solution: value} : sol)),
|
solutions: exercise.solutions.map((sol) => (sol.id === solution.id ? { ...sol, solution: value } : sol)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</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 key={index} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
(
|
||||||
<Input
|
<div className="flex flex-col w-full">
|
||||||
type="text"
|
{exercise.words.flatMap((mcOptions, wordIndex) =>
|
||||||
label={`Word ${index + 1}`}
|
<>
|
||||||
name="word"
|
<label className="font-semibold">{`Word ${wordIndex + 1}`}</label>
|
||||||
required
|
<div className="flex flex-row">
|
||||||
value={typeof word === "string" ? word : ("word" in word ? word.word : "")}
|
{Object.entries(mcOptions.options).map(([key, value], optionIndex) => (
|
||||||
onChange={(value) =>
|
<div key={`${wordIndex}-${optionIndex}-${key}`} className="flex sm:w-1/2 lg:w-1/4 px-2 mb-4">
|
||||||
updateExercise({
|
<Input
|
||||||
words: exercise.words.map((sol, idx) =>
|
type="text"
|
||||||
index === idx ? (typeof word === "string" ? value : {...word, word: value}) : sol,
|
label={`Option ${key}`}
|
||||||
),
|
name="word"
|
||||||
})
|
required
|
||||||
}
|
value={value}
|
||||||
/>
|
onChange={(newValue) =>
|
||||||
</div>
|
updateExercise({
|
||||||
))}
|
words: exercise.words.map((word, idx) =>
|
||||||
|
idx === wordIndex
|
||||||
|
? {
|
||||||
|
...(word as FillBlanksMCOption),
|
||||||
|
options: {
|
||||||
|
...(word as FillBlanksMCOption).options,
|
||||||
|
[key]: newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: word
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
:
|
||||||
|
(
|
||||||
|
exercise.words.map((word, index) => (
|
||||||
|
<div key={index} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label={`Word ${index + 1}`}
|
||||||
|
name="word"
|
||||||
|
required
|
||||||
|
value={typeof word === "string" ? word : ("word" in word ? word.word : "")}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
words: exercise.words.map((sol, idx) =>
|
||||||
|
index === idx ? (typeof word === "string" ? value : { ...word, word: value }) : sol,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,39 +1,47 @@
|
|||||||
import { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
|
import { HighlightConfig, HighlightTarget } from "@/training/TrainingInterfaces";
|
||||||
|
|
||||||
const HighlightContent: React.FC<{
|
interface HighlightedContentProps {
|
||||||
html: string;
|
html: string;
|
||||||
highlightPhrases: string[],
|
highlightConfigs: HighlightConfig[];
|
||||||
firstOccurence?: boolean
|
contentType: HighlightTarget;
|
||||||
}> = ({
|
currentSegmentIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HighlightedContent: React.FC<HighlightedContentProps> = ({
|
||||||
html,
|
html,
|
||||||
highlightPhrases,
|
highlightConfigs,
|
||||||
firstOccurence = false
|
contentType,
|
||||||
|
currentSegmentIndex
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
const createHighlightedContent = useCallback(() => {
|
const createHighlightedContent = useCallback(() => {
|
||||||
if (highlightPhrases.length === 0) {
|
|
||||||
return { __html: html };
|
|
||||||
}
|
|
||||||
|
|
||||||
const escapeRegExp = (string: string) => {
|
|
||||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
};
|
|
||||||
|
|
||||||
const regex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'i');
|
|
||||||
const globalRegex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'gi');
|
|
||||||
|
|
||||||
let highlightedHtml = html;
|
let highlightedHtml = html;
|
||||||
|
highlightConfigs.forEach(config => {
|
||||||
|
if (config.targets.includes(contentType) || config.targets.includes('all')) {
|
||||||
|
const escapeRegExp = (string: string) => {
|
||||||
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
};
|
||||||
|
|
||||||
if (firstOccurence) {
|
const regex = new RegExp(config.phrases.map(escapeRegExp).join('|'), 'g');
|
||||||
highlightedHtml = html.replace(regex, (match) => `<span style="background-color: yellow;">${match}</span>`);
|
|
||||||
} else {
|
if (contentType === 'segment' && currentSegmentIndex !== undefined) {
|
||||||
highlightedHtml = html.replace(globalRegex, (match) => `<span style="background-color: yellow;">${match}</span>`);
|
const segments = highlightedHtml.split('</div>');
|
||||||
}
|
segments[currentSegmentIndex] = segments[currentSegmentIndex].replace(regex, (match) => {
|
||||||
|
return `<span style="background-color: #FFFACD;">${match}</span>`;
|
||||||
|
});
|
||||||
|
highlightedHtml = segments.join('</div>');
|
||||||
|
} else {
|
||||||
|
highlightedHtml = highlightedHtml.replace(regex, (match) => {
|
||||||
|
return `<span style="background-color: #FFFACD;">${match}</span>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return { __html: highlightedHtml };
|
return { __html: highlightedHtml };
|
||||||
}, [html, highlightPhrases, firstOccurence]);
|
}, [html, highlightConfigs, contentType, currentSegmentIndex]);
|
||||||
|
|
||||||
return <div dangerouslySetInnerHTML={createHighlightedContent()} />;
|
return <div dangerouslySetInnerHTML={createHighlightedContent()} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default HighlightContent;
|
export default HighlightedContent;
|
||||||
@@ -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
|
||||||
if(disabled) return;
|
className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer"
|
||||||
onChange(!isChecked);
|
onClick={() => {
|
||||||
}}>
|
if (disabled) return;
|
||||||
|
onChange(!isChecked);
|
||||||
|
}}>
|
||||||
<input type="checkbox" className="hidden" />
|
<input type="checkbox" className="hidden" />
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
|
"w-6 h-6 min-w-6 min-h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
isChecked && "!bg-mti-purple-light ",
|
isChecked && "!bg-mti-purple-light ",
|
||||||
)}>
|
)}>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useState} from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
type: "email" | "text" | "password" | "tel" | "number";
|
type: "email" | "text" | "password" | "tel" | "number" | "textarea";
|
||||||
roundness?: "full" | "xl";
|
roundness?: "full" | "xl";
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -11,6 +11,7 @@ interface Props {
|
|||||||
value?: string | number;
|
value?: string | number;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
max?: number;
|
||||||
name: string;
|
name: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
@@ -23,6 +24,7 @@ export default function Input({
|
|||||||
required = false,
|
required = false,
|
||||||
value,
|
value,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
|
max,
|
||||||
className,
|
className,
|
||||||
roundness = "full",
|
roundness = "full",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -30,6 +32,20 @@ export default function Input({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
if (type === "textarea") {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
className="w-full h-full cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl min-h-[200px]"
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (type === "password") {
|
if (type === "password") {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
@@ -72,6 +88,7 @@ export default function Input({
|
|||||||
name={name}
|
name={name}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
value={value}
|
value={value}
|
||||||
|
max={max}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
min={type === "number" ? 0 : undefined}
|
min={type === "number" ? 0 : undefined}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
|||||||
@@ -1,30 +1,55 @@
|
|||||||
import {Module} from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import useExamStore from "@/stores/examStore";
|
import { moduleLabels } from "@/utils/moduleUtils";
|
||||||
import {moduleLabels} from "@/utils/moduleUtils";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {motion} from "framer-motion";
|
import { ReactNode, useState } from "react";
|
||||||
import {ReactNode, useEffect, useState} from "react";
|
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch } from "react-icons/bs";
|
||||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
|
|
||||||
import ProgressBar from "../Low/ProgressBar";
|
import ProgressBar from "../Low/ProgressBar";
|
||||||
import TimerEndedModal from "../TimerEndedModal";
|
|
||||||
import Timer from "./Timer";
|
import 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 moduleIcon: {[key in Module]: ReactNode} = {
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const moduleIcon: { [key in Module]: ReactNode } = {
|
||||||
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
|
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
|
||||||
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
|
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
|
||||||
writing: <BsPen className="text-ielts-writing w-6 h-6" />,
|
writing: <BsPen className="text-ielts-writing w-6 h-6" />,
|
||||||
@@ -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,8 +168,24 @@ 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,61 +40,71 @@ export default function SessionCard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-mti-gray-anti-flash flex w-64 flex-col gap-3 rounded-xl border p-4 text-black">
|
<div className="border-mti-gray-anti-flash flex w-64 flex-col justify-between gap-3 rounded-xl border p-4 text-black">
|
||||||
<span className="flex gap-1">
|
<div className="flex flex-col gap-3">
|
||||||
<b>ID:</b>
|
<span className="flex gap-1">
|
||||||
{session.sessionId}
|
<b>ID:</b>
|
||||||
</span>
|
{session.sessionId}
|
||||||
<span className="flex gap-1">
|
</span>
|
||||||
<b>Date:</b>
|
<span className="flex gap-1">
|
||||||
{moment(session.date).format("DD/MM/YYYY - HH:mm")}
|
<b>Date:</b>
|
||||||
</span>
|
{moment(session.date).format("DD/MM/YYYY - HH:mm")}
|
||||||
<div className="flex w-full items-center justify-between">
|
</span>
|
||||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-center justify-center gap-2">
|
{session.assignment && (
|
||||||
{session.selectedModules.sort(sortByModuleName).map((module) => (
|
<span className="flex flex-col gap-0">
|
||||||
<div
|
<b>Assignment:</b>
|
||||||
key={module}
|
{session.assignment.name}
|
||||||
data-tip={capitalize(module)}
|
</span>
|
||||||
className={clsx(
|
)}
|
||||||
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
|
||||||
module === "reading" && "bg-ielts-reading",
|
|
||||||
module === "listening" && "bg-ielts-listening",
|
|
||||||
module === "writing" && "bg-ielts-writing",
|
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
|
||||||
module === "level" && "bg-ielts-level",
|
|
||||||
)}>
|
|
||||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
|
||||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
|
||||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
|
||||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 w-full">
|
<div className="flex flex-col gap-3">
|
||||||
<button
|
<div className="flex w-full items-center justify-between">
|
||||||
onClick={async () => await loadSession(session)}
|
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-center justify-center gap-2">
|
||||||
disabled={isLoading}
|
{session.selectedModules.sort(sortByModuleName).map((module) => (
|
||||||
className="bg-mti-green-ultralight w-full hover:bg-mti-green-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
<div
|
||||||
{!isLoading && "Resume"}
|
key={module}
|
||||||
{isLoading && (
|
data-tip={capitalize(module)}
|
||||||
<div className="flex items-center justify-center">
|
className={clsx(
|
||||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||||
</div>
|
module === "reading" && "bg-ielts-reading",
|
||||||
)}
|
module === "listening" && "bg-ielts-listening",
|
||||||
</button>
|
module === "writing" && "bg-ielts-writing",
|
||||||
<button
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
onClick={deleteSession}
|
module === "level" && "bg-ielts-level",
|
||||||
disabled={isLoading}
|
)}>
|
||||||
className="bg-mti-red-ultralight w-full hover:bg-mti-red-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
{!isLoading && "Delete"}
|
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||||
{isLoading && (
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
<div className="flex items-center justify-center">
|
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</button>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 w-full">
|
||||||
|
<button
|
||||||
|
onClick={async () => await loadSession(session)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="bg-mti-green-ultralight w-full hover:bg-mti-green-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
||||||
|
{!isLoading && "Resume"}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={deleteSession}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="bg-mti-red-ultralight w-full hover:bg-mti-red-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
||||||
|
{!isLoading && "Delete"}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
313
src/components/Medium/StatGridItem.tsx
Normal file
313
src/components/Medium/StatGridItem.tsx
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {BsClock, BsXCircle} from "react-icons/bs";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {Stat, User} from "@/interfaces/user";
|
||||||
|
import {Module, Step} from "@/interfaces";
|
||||||
|
import ai_usage from "@/utils/ai.detection";
|
||||||
|
import {calculateBandScore} from "@/utils/score";
|
||||||
|
import moment from "moment";
|
||||||
|
import {Assignment} from "@/interfaces/results";
|
||||||
|
import {uuidv4} from "@firebase/util";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import {uniqBy} from "lodash";
|
||||||
|
import {sortByModule} from "@/utils/moduleUtils";
|
||||||
|
import {convertToUserSolutions} from "@/utils/stats";
|
||||||
|
import {getExamById} from "@/utils/exams";
|
||||||
|
import {Exam, UserSolution} from "@/interfaces/exam";
|
||||||
|
import ModuleBadge from "../ModuleBadge";
|
||||||
|
|
||||||
|
const formatTimestamp = (timestamp: string | number) => {
|
||||||
|
const time = typeof timestamp === "string" ? parseInt(timestamp) : timestamp;
|
||||||
|
const date = moment(time);
|
||||||
|
const formatter = "YYYY/MM/DD - HH:mm";
|
||||||
|
return date.format(formatter);
|
||||||
|
};
|
||||||
|
|
||||||
|
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
||||||
|
const scores: {
|
||||||
|
[key in Module]: {total: number; missing: number; correct: number};
|
||||||
|
} = {
|
||||||
|
reading: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
listening: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
writing: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
speaking: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
level: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
stats.forEach((x) => {
|
||||||
|
scores[x.module!] = {
|
||||||
|
total: scores[x.module!].total + x.score.total,
|
||||||
|
correct: scores[x.module!].correct + x.score.correct,
|
||||||
|
missing: scores[x.module!].missing + x.score.missing,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.keys(scores)
|
||||||
|
.filter((x) => scores[x as Module].total > 0)
|
||||||
|
.map((x) => ({module: x as Module, ...scores[x as Module]}));
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StatsGridItemProps {
|
||||||
|
width?: string | undefined;
|
||||||
|
height?: string | undefined;
|
||||||
|
examNumber?: number | undefined;
|
||||||
|
stats: Stat[];
|
||||||
|
timestamp: string | number;
|
||||||
|
user: User;
|
||||||
|
assignments: Assignment[];
|
||||||
|
users: User[];
|
||||||
|
training?: boolean;
|
||||||
|
gradingSystem?: Step[];
|
||||||
|
selectedTrainingExams?: string[];
|
||||||
|
maxTrainingExams?: number;
|
||||||
|
setSelectedTrainingExams?: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
|
setExams: (exams: Exam[]) => void;
|
||||||
|
setShowSolutions: (show: boolean) => void;
|
||||||
|
setUserSolutions: (solutions: UserSolution[]) => void;
|
||||||
|
setSelectedModules: (modules: Module[]) => void;
|
||||||
|
setInactivity: (inactivity: number) => void;
|
||||||
|
setTimeSpent: (time: number) => void;
|
||||||
|
renderPdfIcon: (session: string, color: string, textColor: string) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatsGridItem: React.FC<StatsGridItemProps> = ({
|
||||||
|
stats,
|
||||||
|
timestamp,
|
||||||
|
user,
|
||||||
|
assignments,
|
||||||
|
users,
|
||||||
|
training,
|
||||||
|
selectedTrainingExams,
|
||||||
|
gradingSystem,
|
||||||
|
setSelectedTrainingExams,
|
||||||
|
setExams,
|
||||||
|
setShowSolutions,
|
||||||
|
setUserSolutions,
|
||||||
|
setSelectedModules,
|
||||||
|
setInactivity,
|
||||||
|
setTimeSpent,
|
||||||
|
renderPdfIcon,
|
||||||
|
width = undefined,
|
||||||
|
height = undefined,
|
||||||
|
examNumber = undefined,
|
||||||
|
maxTrainingExams = undefined,
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
||||||
|
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
||||||
|
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
||||||
|
const assignmentID = stats.reduce((_, current) => current.assignment as any, "");
|
||||||
|
const assignment = assignments.find((a) => a.id === assignmentID);
|
||||||
|
const isDisabled = stats.some((x) => x.isDisabled);
|
||||||
|
|
||||||
|
const aiUsage = Math.round(ai_usage(stats) * 100);
|
||||||
|
|
||||||
|
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||||
|
module: x.module,
|
||||||
|
level: calculateBandScore(x.correct, x.total, x.module, user.focus),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const textColor = clsx(
|
||||||
|
correct / total >= 0.7 && "text-mti-purple",
|
||||||
|
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||||
|
correct / total < 0.3 && "text-mti-rose",
|
||||||
|
);
|
||||||
|
|
||||||
|
const {timeSpent, inactivity, session} = stats[0];
|
||||||
|
|
||||||
|
const selectExam = () => {
|
||||||
|
if (
|
||||||
|
training &&
|
||||||
|
!isDisabled &&
|
||||||
|
typeof maxTrainingExams !== "undefined" &&
|
||||||
|
typeof setSelectedTrainingExams !== "undefined" &&
|
||||||
|
typeof timestamp == "string"
|
||||||
|
) {
|
||||||
|
setSelectedTrainingExams((prevExams) => {
|
||||||
|
const uniqueExams = [...new Set(stats.map((stat) => `${stat.module}-${stat.date}`))];
|
||||||
|
const indexes = uniqueExams.map((exam) => prevExams.indexOf(exam)).filter((index) => index !== -1);
|
||||||
|
if (indexes.length > 0) {
|
||||||
|
const newExams = [...prevExams];
|
||||||
|
indexes
|
||||||
|
.sort((a, b) => b - a)
|
||||||
|
.forEach((index) => {
|
||||||
|
newExams.splice(index, 1);
|
||||||
|
});
|
||||||
|
return newExams;
|
||||||
|
} else {
|
||||||
|
if (prevExams.length + uniqueExams.length <= maxTrainingExams) {
|
||||||
|
return [...prevExams, ...uniqueExams];
|
||||||
|
} else {
|
||||||
|
return prevExams;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const examPromises = uniqBy(stats, "exam").map((stat) => {
|
||||||
|
return getExamById(stat.module, stat.exam);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDisabled) return;
|
||||||
|
|
||||||
|
Promise.all(examPromises).then((exams) => {
|
||||||
|
if (exams.every((x) => !!x)) {
|
||||||
|
if (!!timeSpent) setTimeSpent(timeSpent);
|
||||||
|
if (!!inactivity) setInactivity(inactivity);
|
||||||
|
setUserSolutions(convertToUserSolutions(stats));
|
||||||
|
setShowSolutions(true);
|
||||||
|
setExams(exams.map((x) => x!).sort(sortByModule));
|
||||||
|
setSelectedModules(
|
||||||
|
exams
|
||||||
|
.map((x) => x!)
|
||||||
|
.sort(sortByModule)
|
||||||
|
.map((x) => x!.module),
|
||||||
|
);
|
||||||
|
router.push("/exercises");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldRenderPDFIcon = () => {
|
||||||
|
if (assignment) {
|
||||||
|
return assignment.released;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
|
||||||
|
<div className="flex flex-col md:gap-1 -md:gap-2 2xl:gap-2">
|
||||||
|
<span className="font-medium">{formatTimestamp(timestamp)}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!!timeSpent && (
|
||||||
|
<span className="text-sm flex gap-2 items-center tooltip" data-tip="Time Spent">
|
||||||
|
<BsClock /> {Math.floor(timeSpent / 60)} minutes
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!!inactivity && (
|
||||||
|
<span className="text-sm flex gap-2 items-center tooltip" data-tip="Inactivity">
|
||||||
|
<BsXCircle /> {Math.floor(inactivity / 60)} minutes
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
{!!assignment && (assignment.released || assignment.released === undefined) && (
|
||||||
|
<span className={textColor}>
|
||||||
|
Level{" "}
|
||||||
|
{(
|
||||||
|
aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length
|
||||||
|
).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{shouldRenderPDFIcon() && renderPdfIcon(session, textColor, textColor)}
|
||||||
|
</div>
|
||||||
|
{examNumber === undefined ? (
|
||||||
|
<>
|
||||||
|
{aiUsage >= 50 && user.type !== "student" && (
|
||||||
|
<div
|
||||||
|
className={clsx("ml-auto border px-1 rounded w-fit mr-1", {
|
||||||
|
"bg-orange-100 border-orange-400 text-orange-700": aiUsage < 80,
|
||||||
|
"bg-red-100 border-red-400 text-red-700": aiUsage >= 80,
|
||||||
|
})}>
|
||||||
|
<span className="text-xs">AI Usage</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<span className="font-semibold bg-gray-200 text-gray-800 px-2.5 py-0.5 rounded-full mt-0.5">{examNumber}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full flex flex-col gap-1">
|
||||||
|
<div className={clsx("grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2", examNumber !== undefined && "pr-10")}>
|
||||||
|
{!!assignment &&
|
||||||
|
(assignment.released || assignment.released === undefined) &&
|
||||||
|
aggregatedLevels.map(({module, level}) => <ModuleBadge key={module} module={module} level={level} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{assignment && (
|
||||||
|
<span className="font-light text-sm">
|
||||||
|
Assignment: {assignment.name}, Teacher: {users.find((u) => u.id === assignment.assigner)?.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
key={uuidv4()}
|
||||||
|
className={clsx(
|
||||||
|
"flex flex-col justify-between gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden",
|
||||||
|
(isDisabled || (!!assignment && !assignment.released)) && "grayscale tooltip",
|
||||||
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
|
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||||
|
correct / total < 0.3 && "hover:border-mti-rose",
|
||||||
|
typeof selectedTrainingExams !== "undefined" &&
|
||||||
|
typeof timestamp === "string" &&
|
||||||
|
selectedTrainingExams.some((exam) => exam.includes(timestamp)) &&
|
||||||
|
"border-2 border-slate-600",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!!assignment && !assignment.released) return;
|
||||||
|
if (examNumber === undefined) return selectExam();
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
...(width !== undefined && {width}),
|
||||||
|
...(height !== undefined && {height}),
|
||||||
|
}}
|
||||||
|
data-tip={isDisabled ? "This exam is still being evaluated..." : "This exam is still locked by its assigner..."}
|
||||||
|
role="button">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
key={uuidv4()}
|
||||||
|
className={clsx(
|
||||||
|
"flex flex-col gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:tooltip md:hidden",
|
||||||
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
|
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||||
|
correct / total < 0.3 && "hover:border-mti-rose",
|
||||||
|
)}
|
||||||
|
data-tip="Your screen size is too small to view previous exams."
|
||||||
|
style={{
|
||||||
|
...(width !== undefined && {width}),
|
||||||
|
...(height !== undefined && {height}),
|
||||||
|
}}
|
||||||
|
role="button">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatsGridItem;
|
||||||
@@ -1,80 +1,80 @@
|
|||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import { useEffect, useState } from "react";
|
import {useEffect, useState} from "react";
|
||||||
import { motion } from "framer-motion";
|
import {motion} from "framer-motion";
|
||||||
import TimerEndedModal from "../TimerEndedModal";
|
import TimerEndedModal from "../TimerEndedModal";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { BsStopwatch } from "react-icons/bs";
|
import {BsStopwatch} from "react-icons/bs";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
disableTimer?: boolean;
|
disableTimer?: boolean;
|
||||||
standalone?: boolean;
|
standalone?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Timer: React.FC<Props> = ({minTimer, disableTimer, standalone = false}) => {
|
const Timer: React.FC<Props> = ({minTimer, disableTimer, standalone = false}) => {
|
||||||
const [timer, setTimer] = useState(minTimer * 60);
|
const [timer, setTimer] = useState(minTimer * 60);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [warningMode, setWarningMode] = useState(false);
|
const [warningMode, setWarningMode] = useState(false);
|
||||||
|
|
||||||
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
||||||
const { timeSpent } = useExamStore((state) => state);
|
const {timeSpent} = useExamStore((state) => state);
|
||||||
|
|
||||||
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
|
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!disableTimer) {
|
if (!disableTimer) {
|
||||||
const timerInterval = setInterval(() => setTimer((prev) => prev - 1), 1000);
|
const timerInterval = setInterval(() => setTimer((prev) => prev - 1), 1000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(timerInterval);
|
clearInterval(timerInterval);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [disableTimer, minTimer]);
|
}, [disableTimer, minTimer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (timer <= 0) setShowModal(true);
|
if (timer <= 0) setShowModal(true);
|
||||||
}, [timer]);
|
}, [timer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (timer < 300 && !warningMode) setWarningMode(true);
|
if (timer < 300 && !warningMode) setWarningMode(true);
|
||||||
}, [timer, warningMode]);
|
}, [timer, warningMode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TimerEndedModal
|
<TimerEndedModal
|
||||||
isOpen={showModal}
|
isOpen={showModal}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setHasExamEnded(true);
|
setHasExamEnded(true);
|
||||||
setShowModal(false);
|
setShowModal(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)
|
||||||
.toString(10)
|
.toString(10)
|
||||||
.padStart(2, "0")}
|
.padStart(2, "0")}
|
||||||
:
|
:
|
||||||
{Math.floor(timer % 60)
|
{Math.floor(timer % 60)
|
||||||
.toString(10)
|
.toString(10)
|
||||||
.padStart(2, "0")}
|
.padStart(2, "0")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{timer <= 0 && <>00:00</>}
|
{timer <= 0 && <>00:00</>}
|
||||||
</span>
|
</span>
|
||||||
</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,24 +1,34 @@
|
|||||||
|
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}> = ({
|
||||||
<div
|
module,
|
||||||
className={clsx(
|
level,
|
||||||
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
|
gradingSystem,
|
||||||
module === "reading" && "bg-ielts-reading",
|
className,
|
||||||
module === "listening" && "bg-ielts-listening",
|
}) => (
|
||||||
module === "writing" && "bg-ielts-writing",
|
<div
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
className={clsx(
|
||||||
module === "level" && "bg-ielts-level",
|
"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" && <BsBook className="w-4 h-4" />}
|
module === "listening" && "bg-ielts-listening",
|
||||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
module === "writing" && "bg-ielts-writing",
|
||||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
module === "level" && "bg-ielts-level",
|
||||||
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
className,
|
||||||
{/* do not switch to level && it will convert the 0.0 to 0*/}
|
)}>
|
||||||
{level !== undefined && (<span className="text-sm">{level.toFixed(1)}</span>)}
|
{module === "reading" && <BsBook className="w-4 h-4" />}
|
||||||
</div>
|
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
||||||
|
{module === "writing" && <BsPen className="w-4 h-4" />}
|
||||||
|
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
||||||
|
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
||||||
|
{/* do not switch to level && it will convert the 0.0 to 0*/}
|
||||||
|
{level !== undefined && (
|
||||||
|
<span className="text-sm">{module === "level" && gradingSystem ? getGradingLabel(level, gradingSystem) : level.toFixed(1)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default ModuleBadge;
|
export default ModuleBadge;
|
||||||
|
|||||||
@@ -1,219 +1,165 @@
|
|||||||
import { User } from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import FocusLayer from "@/components/FocusLayer";
|
import FocusLayer from "@/components/FocusLayer";
|
||||||
import { preventNavigation } from "@/utils/navigation.disabled";
|
import {preventNavigation} from "@/utils/navigation.disabled";
|
||||||
import { useRouter } from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import { BsList, BsQuestionCircle, BsQuestionCircleFill } from "react-icons/bs";
|
import {BsList, BsQuestionCircle, BsQuestionCircleFill} from "react-icons/bs";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import MobileMenu from "./MobileMenu";
|
import MobileMenu from "./MobileMenu";
|
||||||
import { useEffect, useState } from "react";
|
import {useEffect, useState} from "react";
|
||||||
import { Type } from "@/interfaces/user";
|
import {Type} from "@/interfaces/user";
|
||||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import { isUserFromCorporate } from "@/utils/groups";
|
import {isUserFromCorporate} from "@/utils/groups";
|
||||||
import Button from "./Low/Button";
|
import Button from "./Low/Button";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
import Input from "./Low/Input";
|
import Input from "./Low/Input";
|
||||||
import TicketSubmission from "./High/TicketSubmission";
|
import TicketSubmission from "./High/TicketSubmission";
|
||||||
import { Module } from "@/interfaces";
|
import {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;
|
||||||
focusMode?: boolean;
|
focusMode?: boolean;
|
||||||
onFocusLayerMouseEnter?: () => void;
|
onFocusLayerMouseEnter?: () => void;
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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,
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
path,
|
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
||||||
navDisabled = false,
|
const [isTicketOpen, setIsTicketOpen] = useState(false);
|
||||||
focusMode = false,
|
|
||||||
onFocusLayerMouseEnter,
|
|
||||||
}: Props) {
|
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
||||||
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
|
||||||
const [isTicketOpen, setIsTicketOpen] = useState(false);
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||||
|
|
||||||
const expirationDateColor = (date: Date) => {
|
const expirationDateColor = (date: Date) => {
|
||||||
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 = () => {
|
||||||
if (!user.subscriptionExpirationDate) return false;
|
if (!user.subscriptionExpirationDate) return false;
|
||||||
|
|
||||||
const momentDate = moment(user.subscriptionExpirationDate);
|
const momentDate = moment(user.subscriptionExpirationDate);
|
||||||
const today = moment(new Date());
|
const today = moment(new Date());
|
||||||
|
|
||||||
return today.add(7, "days").isAfter(momentDate);
|
return today.add(7, "days").isAfter(momentDate);
|
||||||
};
|
};
|
||||||
|
|
||||||
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) =>
|
}, [user]);
|
||||||
setDisablePaymentPage(result)
|
|
||||||
);
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const badges = [
|
const badges = [
|
||||||
{
|
{
|
||||||
module: "reading",
|
module: "reading",
|
||||||
icon: () => <BsBook className="h-4 w-4 text-white" />,
|
icon: () => <BsBook className="h-4 w-4 text-white" />,
|
||||||
achieved: user.levels.reading >= user.desiredLevels.reading,
|
achieved: user.levels.reading >= user.desiredLevels.reading,
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
module: "listening",
|
module: "listening",
|
||||||
icon: () => <BsHeadphones className="h-4 w-4 text-white" />,
|
icon: () => <BsHeadphones className="h-4 w-4 text-white" />,
|
||||||
achieved: user.levels.listening >= user.desiredLevels.listening,
|
achieved: user.levels.listening >= user.desiredLevels.listening,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
module: "writing",
|
module: "writing",
|
||||||
icon: () => <BsPen className="h-4 w-4 text-white" />,
|
icon: () => <BsPen className="h-4 w-4 text-white" />,
|
||||||
achieved: user.levels.writing >= user.desiredLevels.writing,
|
achieved: user.levels.writing >= user.desiredLevels.writing,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
module: "speaking",
|
module: "speaking",
|
||||||
icon: () => <BsMegaphone className="h-4 w-4 text-white" />,
|
icon: () => <BsMegaphone className="h-4 w-4 text-white" />,
|
||||||
achieved: user.levels.speaking >= user.desiredLevels.speaking,
|
achieved: user.levels.speaking >= user.desiredLevels.speaking,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
module: "level",
|
module: "level",
|
||||||
icon: () => <BsClipboard className="h-4 w-4 text-white" />,
|
icon: () => <BsClipboard className="h-4 w-4 text-white" />,
|
||||||
achieved: user.levels.level >= user.desiredLevels.level,
|
achieved: user.levels.level >= user.desiredLevels.level,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
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)}
|
</Modal>
|
||||||
title="Submit a ticket"
|
|
||||||
>
|
|
||||||
<TicketSubmission
|
|
||||||
user={user}
|
|
||||||
page={router.asPath}
|
|
||||||
onClose={() => setIsTicketOpen(false)}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<MobileMenu
|
<MobileMenu disableNavigation={disableNavigation} path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />
|
||||||
disableNavigation={disableNavigation}
|
)}
|
||||||
path={path}
|
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
|
||||||
isOpen={isMenuOpen}
|
<Link href={disableNavigation ? "" : "/"} className=" flex items-center gap-8 md:px-8">
|
||||||
onClose={() => setIsMenuOpen(false)}
|
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
|
||||||
user={user}
|
<h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1>
|
||||||
/>
|
</Link>
|
||||||
)}
|
<div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6">
|
||||||
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
|
{user.type === "student" &&
|
||||||
<Link
|
badges.map((badge) => (
|
||||||
href={disableNavigation ? "" : "/"}
|
<div
|
||||||
className=" flex items-center gap-8 md:px-8"
|
key={badge.module}
|
||||||
>
|
className={`${
|
||||||
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
|
badge.achieved ? `bg-ielts-${badge.module}` : "bg-mti-gray-anti-flash"
|
||||||
<h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1>
|
} flex h-8 w-8 items-center justify-center rounded-full`}>
|
||||||
</Link>
|
{badge.icon()}
|
||||||
<div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6">
|
</div>
|
||||||
{user.type === "student" &&
|
))}
|
||||||
badges.map((badge) => (
|
{/* OPEN TICKET SYSTEM */}
|
||||||
<div
|
<button
|
||||||
key={badge.module}
|
className={clsx(
|
||||||
className={`${badge.achieved ? `bg-ielts-${badge.module}`: 'bg-mti-gray-anti-flash'} flex h-8 w-8 items-center justify-center rounded-full`}
|
"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",
|
||||||
{badge.icon()}
|
)}
|
||||||
</div>
|
data-tip="Submit a help/feedback ticket"
|
||||||
))}
|
onClick={() => setIsTicketOpen(true)}>
|
||||||
{/* OPEN TICKET SYSTEM */}
|
<BsQuestionCircleFill />
|
||||||
<button
|
</button>
|
||||||
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",
|
|
||||||
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white z-20"
|
|
||||||
)}
|
|
||||||
data-tip="Submit a help/feedback ticket"
|
|
||||||
onClick={() => setIsTicketOpen(true)}
|
|
||||||
>
|
|
||||||
<BsQuestionCircleFill />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showExpirationDate() && (
|
{showExpirationDate() && (
|
||||||
<Link
|
<Link
|
||||||
href={
|
href={!!user.subscriptionExpirationDate && !disablePaymentPage ? "/payment" : ""}
|
||||||
!!user.subscriptionExpirationDate && !disablePaymentPage
|
data-tip="Expiry date"
|
||||||
? "/payment"
|
className={clsx(
|
||||||
: ""
|
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none",
|
||||||
}
|
"tooltip tooltip-bottom transition duration-300 ease-in-out",
|
||||||
data-tip="Expiry date"
|
!user.subscriptionExpirationDate
|
||||||
className={clsx(
|
? "bg-mti-green-ultralight border-mti-green-light"
|
||||||
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none",
|
: expirationDateColor(user.subscriptionExpirationDate),
|
||||||
"tooltip tooltip-bottom transition duration-300 ease-in-out",
|
"border-mti-gray-platinum bg-white",
|
||||||
!user.subscriptionExpirationDate
|
)}>
|
||||||
? "bg-mti-green-ultralight border-mti-green-light"
|
{!user.subscriptionExpirationDate && "Unlimited"}
|
||||||
: expirationDateColor(user.subscriptionExpirationDate),
|
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
||||||
"border-mti-gray-platinum bg-white"
|
</Link>
|
||||||
)}
|
)}
|
||||||
>
|
<Link href={disableNavigation ? "" : "/profile"} className="-md:hidden flex items-center justify-end gap-6">
|
||||||
{!user.subscriptionExpirationDate && "Unlimited"}
|
<img src={user.profilePicture} alt={user.name} className="h-10 w-10 rounded-full object-cover" />
|
||||||
{user.subscriptionExpirationDate &&
|
<span className="-md:hidden text-right">
|
||||||
moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
{(user.type === "corporate" || user.type === "mastercorporate") && !!user.corporateInformation?.companyInformation?.name
|
||||||
</Link>
|
? `${user.corporateInformation?.companyInformation.name} |`
|
||||||
)}
|
: ""}{" "}
|
||||||
<Link
|
{user.name} | {USER_TYPE_LABELS[user.type]}
|
||||||
href={disableNavigation ? "" : "/profile"}
|
{user.type === "corporate" &&
|
||||||
className="-md:hidden flex items-center justify-end gap-6"
|
!!user.demographicInformation?.position &&
|
||||||
>
|
` | ${user.demographicInformation?.position || "N/A"}`}
|
||||||
<img
|
</span>
|
||||||
src={user.profilePicture}
|
</Link>
|
||||||
alt={user.name}
|
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
|
||||||
className="h-10 w-10 rounded-full object-cover"
|
<BsList className="text-mti-purple-light h-8 w-8" />
|
||||||
/>
|
</div>
|
||||||
<span className="-md:hidden text-right">
|
</div>
|
||||||
{user.type === "corporate"
|
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||||
? `${user.corporateInformation?.companyInformation.name} |`
|
</header>
|
||||||
: ""}{" "}
|
</>
|
||||||
{user.name} | {USER_TYPE_LABELS[user.type]}
|
);
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
<div
|
|
||||||
className="cursor-pointer md:hidden"
|
|
||||||
onClick={() => setIsMenuOpen(true)}
|
|
||||||
>
|
|
||||||
<BsList className="text-mti-purple-light h-8 w-8" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{focusMode && (
|
|
||||||
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
|
|
||||||
)}
|
|
||||||
</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,41 +1,30 @@
|
|||||||
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 ".";
|
||||||
import { Fragment } from "react";
|
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,39 +33,38 @@ 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;
|
||||||
}).length;
|
}).length;
|
||||||
const missing = total - correctUserSolutions!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
const missing = total - correctUserSolutions!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||||
return { total, correct, missing };
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
|
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 &&
|
||||||
@@ -199,18 +202,19 @@ export default function FillBlanksSolutions({
|
|||||||
<Button
|
<Button
|
||||||
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>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
onClick={() => onNext({ exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type })}
|
onClick={() => onNext({exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type})}
|
||||||
className="max-w-[200px] self-end w-full">
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import { InteractiveSpeakingExercise } from "@/interfaces/exam";
|
import {InteractiveSpeakingExercise} from "@/interfaces/exam";
|
||||||
import { CommonProps } from ".";
|
import {CommonProps} from ".";
|
||||||
import { useEffect, useState } from "react";
|
import {useEffect, useState} from "react";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { speakingReverseMarking } from "@/utils/score";
|
import {speakingReverseMarking} from "@/utils/score";
|
||||||
import { Tab } from "@headlessui/react";
|
import {Tab} from "@headlessui/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Modal from "../Modal";
|
import Modal from "../Modal";
|
||||||
import ReactDiffViewer, { DiffMethod } from "react-diff-viewer";
|
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
||||||
|
|
||||||
const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
|
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||||
|
|
||||||
export default function InteractiveSpeaking({
|
export default function InteractiveSpeaking({
|
||||||
id,
|
id,
|
||||||
@@ -26,20 +26,24 @@ export default function InteractiveSpeaking({
|
|||||||
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
|
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
|
||||||
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(() => {
|
||||||
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
|
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
|
||||||
Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, { path: x.answer }, { responseType: "arraybuffer" }))).then(
|
Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, {path: x.answer}, {responseType: "arraybuffer"}))).then(
|
||||||
(values) => {
|
(values) => {
|
||||||
setSolutionsURL(
|
setSolutionsURL(
|
||||||
values.map(({ data }) => {
|
values.map(({data}) => {
|
||||||
const blob = new Blob([data], { type: "audio/wav" });
|
const blob = new Blob([data], {type: "audio/wav"});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
return url;
|
return url;
|
||||||
@@ -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 &&
|
||||||
@@ -71,13 +109,13 @@ export default function InteractiveSpeaking({
|
|||||||
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
|
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
|
||||||
padding: "32px 28px",
|
padding: "32px 28px",
|
||||||
},
|
},
|
||||||
marker: { display: "none" },
|
marker: {display: "none"},
|
||||||
diffRemoved: { padding: "32px 28px" },
|
diffRemoved: {padding: "32px 28px"},
|
||||||
diffAdded: { padding: "32px 28px" },
|
diffAdded: {padding: "32px 28px"},
|
||||||
|
|
||||||
wordRemoved: { padding: "0px", display: "initial" },
|
wordRemoved: {padding: "0px", display: "initial"},
|
||||||
wordAdded: { padding: "0px", display: "initial" },
|
wordAdded: {padding: "0px", display: "initial"},
|
||||||
wordDiff: { padding: "0px", display: "initial" },
|
wordDiff: {padding: "0px", display: "initial"},
|
||||||
}}
|
}}
|
||||||
oldValue={userSolutions[0].evaluation[`transcript_${diffNumber}`]?.replaceAll("\\n", "\n")}
|
oldValue={userSolutions[0].evaluation[`transcript_${diffNumber}`]?.replaceAll("\\n", "\n")}
|
||||||
newValue={userSolutions[0].evaluation[`fixed_text_${diffNumber}`]?.replaceAll("\\n", "\n")}
|
newValue={userSolutions[0].evaluation[`fixed_text_${diffNumber}`]?.replaceAll("\\n", "\n")}
|
||||||
@@ -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,20 +182,24 @@ 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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{userSolutions[0].evaluation &&
|
{userSolutions[0].evaluation &&
|
||||||
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length > 0 ? (
|
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length > 0 ? (
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
||||||
<Tab
|
<Tab
|
||||||
className={({ selected }) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
"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",
|
||||||
@@ -168,7 +210,7 @@ export default function InteractiveSpeaking({
|
|||||||
General Feedback
|
General Feedback
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
className={({ selected }) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
"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",
|
||||||
@@ -178,20 +220,26 @@ 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)
|
||||||
<Tab
|
.filter((x) => x.startsWith("perfect_answer"))
|
||||||
key={key}
|
.map((key, index) => (
|
||||||
className={({ selected }) =>
|
<Tab
|
||||||
clsx(
|
key={key}
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
className={({selected}) =>
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
clsx(
|
||||||
"transition duration-300 ease-in-out",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
)
|
"transition duration-300 ease-in-out",
|
||||||
}>
|
selected
|
||||||
Recommended Answer<br />(Prompt {index + 1})
|
? "bg-white shadow"
|
||||||
</Tab>
|
: "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||||
))}
|
)
|
||||||
|
}>
|
||||||
|
Recommended Answer
|
||||||
|
<br />
|
||||||
|
(Prompt {index + 1})
|
||||||
|
</Tab>
|
||||||
|
))}
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels>
|
<Tab.Panels>
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
@@ -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,13 +268,18 @@ 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)
|
||||||
<Tab.Panel key={key} className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
.filter((x) => x.startsWith("perfect_answer"))
|
||||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
.map((key, index) => (
|
||||||
{userSolutions[0].evaluation![`perfect_answer_${(index + 1)}`].answer.replaceAll(/\s{2,}/g, "\n\n")}
|
<Tab.Panel key={key} className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
</span>
|
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||||
</Tab.Panel>
|
{userSolutions[0].evaluation![`perfect_answer_${index + 1}`].answer.replaceAll(
|
||||||
))}
|
/\s{2,}/g,
|
||||||
|
"\n\n",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Tab.Panel>
|
||||||
|
))}
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
) : (
|
) : (
|
||||||
@@ -241,7 +300,7 @@ export default function InteractiveSpeaking({
|
|||||||
onBack({
|
onBack({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: userSolutions,
|
solutions: userSolutions,
|
||||||
score: { total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 },
|
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
||||||
type,
|
type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
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,
|
||||||
@@ -14,37 +14,15 @@ function Question({
|
|||||||
solution,
|
solution,
|
||||||
options,
|
options,
|
||||||
userSolution,
|
userSolution,
|
||||||
}: MultipleChoiceQuestion & { userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean }) {
|
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
||||||
const { userSolutions } = useExamStore((state) => state);
|
const {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>
|
||||||
@@ -103,48 +89,82 @@ 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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const back = () => {
|
const back = () => {
|
||||||
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
|
||||||
{/*<span className="text-xl font-semibold">{prompt}</span>*/}
|
color="purple"
|
||||||
{userSolutions && questionIndex < questions.length && (
|
variant="outline"
|
||||||
<Question
|
onClick={back}
|
||||||
{...questions[questionIndex]}
|
className="max-w-[200px] w-full"
|
||||||
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
/>
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 w-full h-full mb-20 mt-4">
|
||||||
|
<div className="flex flex-col gap-4 mt-2">
|
||||||
|
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
|
{/*<span className="text-xl font-semibold">{prompt}</span>*/}
|
||||||
|
{userSolutions && questionIndex < questions.length && (
|
||||||
|
<Question
|
||||||
|
{...questions[questionIndex]}
|
||||||
|
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{userSolutions && questionIndex + 1 < questions.length && (
|
||||||
|
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
|
<Question
|
||||||
|
{...questions[questionIndex + 1]}
|
||||||
|
userSolution={userSolutions.find((x) => questions[questionIndex + 1].id === x.question)?.option}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-purple" />
|
<div className="w-4 h-4 rounded-full bg-mti-purple" />
|
||||||
@@ -159,14 +179,15 @@ export default function MultipleChoice({ id, type, prompt, questions, userSoluti
|
|||||||
Wrong
|
Wrong
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import { SpeakingExercise } from "@/interfaces/exam";
|
import {SpeakingExercise} from "@/interfaces/exam";
|
||||||
import { CommonProps } from ".";
|
import {CommonProps} from ".";
|
||||||
import { Fragment, useEffect, useState } from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { speakingReverseMarking } from "@/utils/score";
|
import {speakingReverseMarking} from "@/utils/score";
|
||||||
import { Tab } from "@headlessui/react";
|
import {Tab} from "@headlessui/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Modal from "../Modal";
|
import Modal from "../Modal";
|
||||||
import { BsQuestionCircleFill } from "react-icons/bs";
|
import {BsQuestionCircleFill} from "react-icons/bs";
|
||||||
import ReactDiffViewer, { DiffMethod } from "react-diff-viewer";
|
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
||||||
|
|
||||||
const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
|
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||||
|
|
||||||
export default function Speaking({ id, type, title, video_url, text, prompts, userSolutions, onNext, onBack }: SpeakingExercise & CommonProps) {
|
export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||||
const [solutionURL, setSolutionURL] = useState<string>();
|
const [solutionURL, setSolutionURL] = useState<string>();
|
||||||
const [showDiff, setShowDiff] = useState(false);
|
const [showDiff, setShowDiff] = useState(false);
|
||||||
|
|
||||||
@@ -23,8 +23,8 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
|
|||||||
const solution = userSolutions[0].solution;
|
const solution = userSolutions[0].solution;
|
||||||
|
|
||||||
if (solution.startsWith("https://")) return setSolutionURL(solution);
|
if (solution.startsWith("https://")) return setSolutionURL(solution);
|
||||||
axios.post(`/api/speaking`, { path: userSolutions[0].solution }, { responseType: "arraybuffer" }).then(({ data }) => {
|
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
|
||||||
const blob = new Blob([data], { type: "audio/wav" });
|
const blob = new Blob([data], {type: "audio/wav"});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
setSolutionURL(url);
|
setSolutionURL(url);
|
||||||
@@ -32,15 +32,53 @@ 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 &&
|
||||||
@@ -58,13 +96,13 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
|
|||||||
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
|
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
|
||||||
padding: "32px 28px",
|
padding: "32px 28px",
|
||||||
},
|
},
|
||||||
marker: { display: "none" },
|
marker: {display: "none"},
|
||||||
diffRemoved: { padding: "32px 28px" },
|
diffRemoved: {padding: "32px 28px"},
|
||||||
diffAdded: { padding: "32px 28px" },
|
diffAdded: {padding: "32px 28px"},
|
||||||
|
|
||||||
wordRemoved: { padding: "0px", display: "initial" },
|
wordRemoved: {padding: "0px", display: "initial"},
|
||||||
wordAdded: { padding: "0px", display: "initial" },
|
wordAdded: {padding: "0px", display: "initial"},
|
||||||
wordDiff: { padding: "0px", display: "initial" },
|
wordDiff: {padding: "0px", display: "initial"},
|
||||||
}}
|
}}
|
||||||
oldValue={userSolutions[0].evaluation.transcript_1.replaceAll("\\n", "\n")}
|
oldValue={userSolutions[0].evaluation.transcript_1.replaceAll("\\n", "\n")}
|
||||||
newValue={userSolutions[0].evaluation.fixed_text_1.replaceAll("\\n", "\n")}
|
newValue={userSolutions[0].evaluation.fixed_text_1.replaceAll("\\n", "\n")}
|
||||||
@@ -138,20 +176,24 @@ 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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{userSolutions[0].evaluation &&
|
{userSolutions[0].evaluation &&
|
||||||
(userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? (
|
(userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? (
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
||||||
<Tab
|
<Tab
|
||||||
className={({ selected }) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
"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",
|
||||||
@@ -162,7 +204,7 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
|
|||||||
General Feedback
|
General Feedback
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
className={({ selected }) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
"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",
|
||||||
@@ -173,7 +215,7 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
|
|||||||
Evaluation
|
Evaluation
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
className={({ selected }) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
"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",
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -236,7 +284,7 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
|
|||||||
onBack({
|
onBack({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: userSolutions,
|
solutions: userSolutions,
|
||||||
score: { total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 },
|
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
||||||
type,
|
type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,70 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import { WritingExercise } from "@/interfaces/exam";
|
import {WritingExercise} from "@/interfaces/exam";
|
||||||
import { CommonProps } from ".";
|
import {CommonProps} from ".";
|
||||||
import { Fragment, useEffect, useState } from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import { Dialog, Tab, Transition } from "@headlessui/react";
|
import {Dialog, Tab, Transition} from "@headlessui/react";
|
||||||
import { writingReverseMarking } from "@/utils/score";
|
import {writingReverseMarking} from "@/utils/score";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import ReactDiffViewer, { DiffMethod } from "react-diff-viewer";
|
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
import AIDetection from "../AIDetection";
|
import AIDetection from "../AIDetection";
|
||||||
|
|
||||||
export default function Writing({ id, type, prompt, attachment, userSolutions, onNext, onBack }: WritingExercise & CommonProps) {
|
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [showDiff, setShowDiff] = useState(false);
|
const [showDiff, setShowDiff] = useState(false);
|
||||||
|
|
||||||
const { user } = useUser();
|
const {user} = useUser();
|
||||||
|
|
||||||
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">
|
||||||
@@ -99,13 +137,13 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
|
|||||||
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
|
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
|
||||||
padding: "32px 28px",
|
padding: "32px 28px",
|
||||||
},
|
},
|
||||||
marker: { display: "none" },
|
marker: {display: "none"},
|
||||||
diffRemoved: { padding: "32px 28px" },
|
diffRemoved: {padding: "32px 28px"},
|
||||||
diffAdded: { padding: "32px 28px" },
|
diffAdded: {padding: "32px 28px"},
|
||||||
|
|
||||||
wordRemoved: { padding: "0px", display: "initial" },
|
wordRemoved: {padding: "0px", display: "initial"},
|
||||||
wordAdded: { padding: "0px", display: "initial" },
|
wordAdded: {padding: "0px", display: "initial"},
|
||||||
wordDiff: { padding: "0px", display: "initial" },
|
wordDiff: {padding: "0px", display: "initial"},
|
||||||
}}
|
}}
|
||||||
oldValue={userSolutions[0].solution.replaceAll("\\n", "\n")}
|
oldValue={userSolutions[0].solution.replaceAll("\\n", "\n")}
|
||||||
newValue={userSolutions[0].evaluation!.fixed_text!.replaceAll("\\n", "\n")}
|
newValue={userSolutions[0].evaluation!.fixed_text!.replaceAll("\\n", "\n")}
|
||||||
@@ -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
|
||||||
"bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
|
className={clsx(
|
||||||
index === 0 && "tooltip-right"
|
"bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
|
||||||
)} key={key} data-tip={tooltips[key] || "No additional information available"}>
|
index === 0 && "tooltip-right",
|
||||||
|
)}
|
||||||
|
key={key}
|
||||||
|
data-tip={tooltips[key] || "No additional information available"}>
|
||||||
{key}: Level {grade}
|
{key}: Level {grade}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -148,7 +189,7 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
|
|||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
|
||||||
<Tab
|
<Tab
|
||||||
className={({ selected }) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||||
@@ -159,7 +200,7 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
|
|||||||
General Feedback
|
General Feedback
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
className={({ selected }) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||||
@@ -170,7 +211,7 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
|
|||||||
Evaluation
|
Evaluation
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
className={({ selected }) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||||
@@ -182,7 +223,7 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
|
|||||||
</Tab>
|
</Tab>
|
||||||
{aiEval && user?.type !== "student" && (
|
{aiEval && user?.type !== "student" && (
|
||||||
<Tab
|
<Tab
|
||||||
className={({ selected }) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -248,7 +295,7 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
|
|||||||
onBack({
|
onBack({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: userSolutions,
|
solutions: userSolutions,
|
||||||
score: { total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 },
|
score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
||||||
type,
|
type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -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;
|
|
||||||
@@ -1,91 +1,43 @@
|
|||||||
import React, { useState, useCallback } from "react";
|
import React from "react";
|
||||||
import ExerciseWalkthrough from "@/training/ExerciseWalkthrough";
|
import ExerciseWalkthrough from "@/training/ExerciseWalkthrough";
|
||||||
import { ITrainingTip, WalkthroughConfigs } from "./TrainingInterfaces";
|
import { ITrainingTip, WalkthroughConfigs } from "./TrainingInterfaces";
|
||||||
|
import formatTip from "./FormatTip";
|
||||||
|
|
||||||
|
|
||||||
// This wrapper is just to test new exercises from the handbook, will be removed when all the tips and exercises are in firestore
|
// This wrapper is just to test new exercises from the handbook, will be removed when all the tips and exercises are in firestore
|
||||||
const TrainingExercise: React.FC<ITrainingTip> = (trainingTip: ITrainingTip) => {
|
const TrainingExercise: React.FC<ITrainingTip> = (trainingTip: ITrainingTip) => {
|
||||||
const leftText = "<div class=\"container mx-auto px-4 overflow-x-auto\"><table class=\"min-w-full bg-white border border-gray-300\"><thead><tr class=\"bg-gray-100\"><th class=\"py-2 px-4 border-b font-semibold text-left\">Category</th><th class=\"py-2 px-4 border-b font-semibold text-left\">Option A</th><th class=\"py-2 px-4 border-b font-semibold text-left\">Option B</th></tr></thead><tbody><tr><td class=\"py-2 px-4 border-b font-medium\">Self</td><td class=\"py-2 px-4 border-b\">You need to take care of yourself and connect with the people around you.</td><td class=\"py-2 px-4 border-b\">Focus on your interests and talents and meet people who are like you.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Home</td><td class=\"py-2 px-4 border-b\">It's a good idea to paint your living room yellow.</td><td class=\"py-2 px-4 border-b\">You should arrange your home so that it makes you feel happy.</td></tr><tr><td class=\"py-2 px-4 border-b font-medium\">Financial Life</td><td class=\"py-2 px-4 border-b\">You can be happy if you have enough money, but don't want money too much.</td><td class=\"py-2 px-4 border-b\">If you waste money on things you don't need, you won't have enough money for things that you do need.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Social Life</td><td class=\"py-2 px-4 border-b\">A good group of friends can increase your happiness.</td><td class=\"py-2 px-4 border-b\">Researchers say that a happy friend can increase our mood by nine percent.</td></tr><tr><td class=\"py-2 px-4 border-b font-medium\">Workplace</td><td class=\"py-2 px-4 border-b\">You spend a lot of time at work, so you should like your workplace.</td><td class=\"py-2 px-4 border-b\">Your boss needs to be someone you enjoy working for.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Community</td><td class=\"py-2 px-4 border-b\">The place where you live is more important for happiness than anything else.</td><td class=\"py-2 px-4 border-b\">Live around people who have the same amount of money as you do.</td></tr></tbody></table></div>";
|
const tip = {
|
||||||
const tip = {
|
"category": "",
|
||||||
category: "Strategy",
|
"embedding": "",
|
||||||
body: "<p>Look for <b>clues to the main idea</b> in the first (and sometimes second) sentence of a paragraph.</p>"
|
"text": "",
|
||||||
}
|
"html": "",
|
||||||
const question = "<div class=\"container mx-auto px-4 py-8\"><h2 class=\"text-2xl font-bold mb-4\">Identifying Main Ideas</h2><p class=\"text-lg leading-relaxed mb-6\">Read the statements below. Circle the main idea in each pair of statements (a or b).</p></div>";
|
"id": "",
|
||||||
const rightTextData: WalkthroughConfigs[] = [
|
"verified": true,
|
||||||
{
|
"standalone": false,
|
||||||
"html": "<div class='bg-blue-100 p-4 rounded-lg mb-4'><h2 class='text-xl font-bold mb-2'>Identifying Main Ideas</h2><p class='text-gray-700 leading-relaxed'>Let's analyze each pair of statements to determine which one represents the main idea. We'll focus on which statement is more general and encompasses the overall concept.</p></div>",
|
"exercise": {
|
||||||
"wordDelay": 200,
|
"question": "",
|
||||||
"holdDelay": 5000,
|
"additional": "",
|
||||||
"highlight": []
|
"segments": []
|
||||||
},
|
}
|
||||||
{
|
|
||||||
"html": "<div class='bg-green-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>1. Self</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You need to take care of yourself and connect with the people around you.</b></p><p class='mt-2'>This statement is more comprehensive, covering both self-care and social connections. Option B is more specific and could be considered a subset of A.</p></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 8000,
|
|
||||||
"highlight": ["You need to take care of yourself and connect with the people around you."]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"html": "<div class='bg-yellow-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>2. Home</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>B. You should arrange your home so that it makes you feel happy.</b></p><p class='mt-2'>This statement is more general and applies to the entire home. Option A is a specific example that could fall under this broader concept.</p></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 8000,
|
|
||||||
"highlight": ["You should arrange your home so that it makes you feel happy."]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"html": "<div class='bg-pink-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>3. Financial Life</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You can be happy if you have enough money, but don't want money too much.</b></p><p class='mt-2'>This statement provides a balanced view of money's role in happiness. Option B is more specific and could be seen as a consequence of wanting money too much.</p></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 8000,
|
|
||||||
"highlight": ["You can be happy if you have enough money, but don't want money too much."]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"html": "<div class='bg-purple-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>4. Social Life</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. A good group of friends can increase your happiness.</b></p><p class='mt-2'>This statement is more general about the impact of friendships. Option B provides a specific statistic that supports this main idea.</p></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 8000,
|
|
||||||
"highlight": ["A good group of friends can increase your happiness."]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"html": "<div class='bg-indigo-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>5. Workplace</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You spend a lot of time at work, so you should like your workplace.</b></p><p class='mt-2'>This statement covers the overall importance of workplace satisfaction. Option B focuses on one specific aspect (the boss) and is less comprehensive.</p></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 8000,
|
|
||||||
"highlight": ["You spend a lot of time at work, so you should like your workplace."]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"html": "<div class='bg-red-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>6. Community</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. The place where you live is more important for happiness than anything else.</b></p><p class='mt-2'>While this statement might be debatable, it's more general and encompasses the overall importance of community. Option B is a specific suggestion about community demographics.</p></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 8000,
|
|
||||||
"highlight": ["The place where you live is more important for happiness than anything else."]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"html": "<div class='bg-orange-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>Key Strategy</h3><p class='text-gray-700 leading-relaxed'>When identifying main ideas:</p><ul class='list-disc pl-5 space-y-2'><li>Look for broader, more encompassing statements</li><li>Consider which statement other ideas could fall under</li><li>Identify which statement provides a general principle rather than a specific example</li></ul></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 8000,
|
|
||||||
"highlight": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"html": "<div class='bg-teal-50 p-4 rounded-lg'><h3 class='text-lg font-semibold mb-2'>Helpful Tip</h3><p class='text-gray-700 leading-relaxed'>Remember to look for clues to the main idea in the first (and sometimes second) sentence of a paragraph. In this exercise, we applied this concept to pairs of statements. This approach can help you quickly identify the central theme or main point in various types of text.</p></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 5000,
|
|
||||||
"highlight": []
|
|
||||||
}
|
}
|
||||||
]
|
|
||||||
|
|
||||||
const mockTip: ITrainingTip = {
|
const mockTip: ITrainingTip = {
|
||||||
id: "some random id",
|
id: "some random id",
|
||||||
tipCategory: tip.category,
|
tipCategory: tip.category,
|
||||||
tipHtml: tip.body,
|
tipHtml: tip.html,
|
||||||
standalone: false,
|
standalone: tip.standalone,
|
||||||
exercise: {
|
exercise: {
|
||||||
question: question,
|
question: tip.exercise.question,
|
||||||
highlightable: leftText,
|
additional: tip.exercise.additional,
|
||||||
segments: rightTextData
|
segments: tip.exercise.segments as WalkthroughConfigs[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const formattedTip = formatTip(mockTip);
|
||||||
<div className="flex flex-col p-10">
|
return (
|
||||||
<ExerciseWalkthrough {...trainingTip}
|
<ExerciseWalkthrough {...formatTip(trainingTip)}
|
||||||
/>
|
/>
|
||||||
</div>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TrainingExercise;
|
export default TrainingExercise;
|
||||||
@@ -1,19 +1,32 @@
|
|||||||
import React, {useState, useEffect, useRef, useCallback} from "react";
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import {animated} from "@react-spring/web";
|
import { animated } from '@react-spring/web';
|
||||||
import {FaRegCirclePlay, FaRegCircleStop} from "react-icons/fa6";
|
import { FaRegCirclePlay, FaRegCircleStop } from "react-icons/fa6";
|
||||||
import HighlightContent from "../HighlightContent";
|
import HighlightContent from '../HighlightContent';
|
||||||
import {ITrainingTip, SegmentRef, TimelineEvent} from "./TrainingInterfaces";
|
import { ITrainingTip, SegmentRef, TimelineEvent, HighlightConfig, InsertHtmlConfig } from './TrainingInterfaces';
|
||||||
|
import Tip from './Tip';
|
||||||
|
|
||||||
|
interface HtmlState {
|
||||||
|
question: string;
|
||||||
|
additional: string;
|
||||||
|
walkthrough: string;
|
||||||
|
}
|
||||||
|
|
||||||
const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
||||||
const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false);
|
const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false);
|
||||||
const [currentTime, setCurrentTime] = useState<number>(0);
|
const [currentTime, setCurrentTime] = useState<number>(0);
|
||||||
const [walkthroughHtml, setWalkthroughHtml] = useState<string>("");
|
const [currentHighlightConfigs, setCurrentHighlightConfigs] = useState<HighlightConfig[]>([]);
|
||||||
const [highlightedPhrases, setHighlightedPhrases] = useState<string[]>([]);
|
|
||||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
|
const [currentSegmentIndex, setCurrentSegmentIndex] = useState<number>(0);
|
||||||
const timelineRef = useRef<TimelineEvent[]>([]);
|
const timelineRef = useRef<TimelineEvent[]>([]);
|
||||||
const animationRef = useRef<number | null>(null);
|
const animationRef = useRef<number | null>(null);
|
||||||
const segmentsRef = useRef<SegmentRef[]>([]);
|
const segmentsRef = useRef<SegmentRef[]>([]);
|
||||||
|
|
||||||
|
const [questionHtml, setQuestionHtml] = useState(tip.exercise?.question || '');
|
||||||
|
const [additionalHtml, setAdditionalHtml] = useState(tip.exercise?.additional || '');
|
||||||
|
const [walkthroughHtml, setWalkthroughHtml] = useState<string>('');
|
||||||
|
const [htmlStates, setHtmlStates] = useState<HtmlState[]>([]);
|
||||||
|
const lastProcessedInsertTime = useRef<number>(-1);
|
||||||
|
|
||||||
const toggleAutoPlay = useCallback(() => {
|
const toggleAutoPlay = useCallback(() => {
|
||||||
setIsAutoPlaying((prev) => {
|
setIsAutoPlaying((prev) => {
|
||||||
if (!prev && currentTime === getMaxTime()) {
|
if (!prev && currentTime === getMaxTime()) {
|
||||||
@@ -33,23 +46,24 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getMaxTime = (): number => {
|
const getMaxTime = (): number => {
|
||||||
return (
|
return tip.exercise?.segments.reduce((sum, segment) =>
|
||||||
tip.exercise?.segments.reduce((sum, segment) => sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0) ?? 0
|
sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0
|
||||||
);
|
) ?? 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeline: TimelineEvent[] = [];
|
const timeline: TimelineEvent[] = [];
|
||||||
let currentTimePosition = 0;
|
let currentTimePosition = 0;
|
||||||
segmentsRef.current = [];
|
segmentsRef.current = [];
|
||||||
|
const newHtmlStates: HtmlState[] = [];
|
||||||
|
|
||||||
tip.exercise?.segments.forEach((segment, index) => {
|
tip.exercise?.segments.forEach((segment, index) => {
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const doc = parser.parseFromString(segment.html, "text/html");
|
const doc = parser.parseFromString(segment.html, 'text/html');
|
||||||
const words: string[] = [];
|
const words: string[] = [];
|
||||||
const walkTree = (node: Node) => {
|
const walkTree = (node: Node) => {
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
words.push(...(node.textContent?.split(/\s+/).filter((word) => word.length > 0) || []));
|
words.push(...(node.textContent?.split(/\s+/).filter(word => word.length > 0) || []));
|
||||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
Array.from(node.childNodes).forEach(walkTree);
|
Array.from(node.childNodes).forEach(walkTree);
|
||||||
}
|
}
|
||||||
@@ -62,69 +76,116 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
|||||||
...segment,
|
...segment,
|
||||||
words: words,
|
words: words,
|
||||||
startTime: currentTimePosition,
|
startTime: currentTimePosition,
|
||||||
endTime: currentTimePosition + textDuration,
|
endTime: currentTimePosition + textDuration
|
||||||
});
|
});
|
||||||
|
|
||||||
timeline.push({
|
timeline.push({
|
||||||
type: "text",
|
type: 'text',
|
||||||
start: currentTimePosition,
|
start: currentTimePosition,
|
||||||
end: currentTimePosition + textDuration,
|
end: currentTimePosition + textDuration,
|
||||||
segmentIndex: index,
|
segmentIndex: index
|
||||||
});
|
});
|
||||||
|
|
||||||
currentTimePosition += textDuration;
|
currentTimePosition += textDuration;
|
||||||
|
|
||||||
timeline.push({
|
timeline.push({
|
||||||
type: "highlight",
|
type: 'highlight',
|
||||||
start: currentTimePosition,
|
start: currentTimePosition,
|
||||||
end: currentTimePosition + segment.holdDelay,
|
end: currentTimePosition + segment.holdDelay,
|
||||||
content: segment.highlight,
|
content: segment.highlight,
|
||||||
segmentIndex: index,
|
segmentIndex: index
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (segment.insertHTML && segment.insertHTML.length > 0) {
|
||||||
|
newHtmlStates.push({
|
||||||
|
question: questionHtml,
|
||||||
|
additional: additionalHtml,
|
||||||
|
walkthrough: walkthroughHtml
|
||||||
|
});
|
||||||
|
timeline.push({
|
||||||
|
type: 'insert',
|
||||||
|
start: currentTimePosition,
|
||||||
|
end: currentTimePosition + segment.holdDelay,
|
||||||
|
segmentIndex: index,
|
||||||
|
content: segment.insertHTML
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
currentTimePosition += segment.holdDelay;
|
currentTimePosition += segment.holdDelay;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < timeline.length; i++) {
|
||||||
|
if (timeline[i].type === 'insert') {
|
||||||
|
const nextInsertIndex = timeline.findIndex((event, index) => index > i && event.type === 'insert');
|
||||||
|
if (nextInsertIndex !== -1) {
|
||||||
|
timeline[i].end = timeline[nextInsertIndex].start;
|
||||||
|
} else {
|
||||||
|
timeline[i].end = currentTimePosition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
timelineRef.current = timeline;
|
timelineRef.current = timeline;
|
||||||
}, [tip.exercise?.segments]);
|
setHtmlStates(newHtmlStates);
|
||||||
|
}, [tip.exercise?.segments, questionHtml, additionalHtml, walkthroughHtml]);
|
||||||
|
|
||||||
const updateText = useCallback(() => {
|
const updateText = useCallback(() => {
|
||||||
const currentEvent = timelineRef.current.find((event) => currentTime >= event.start && currentTime < event.end);
|
const currentEvents = timelineRef.current.filter(
|
||||||
|
event => currentTime >= event.start && currentTime <= event.end
|
||||||
|
);
|
||||||
|
|
||||||
if (currentEvent) {
|
if (currentTime < lastProcessedInsertTime.current) {
|
||||||
if (currentEvent.type === "text") {
|
const lastInsertEvent = timelineRef.current
|
||||||
|
.filter(event => event.type === 'insert' && event.start <= currentTime)
|
||||||
|
.pop();
|
||||||
|
|
||||||
|
if (lastInsertEvent) {
|
||||||
|
const stateIndex = timelineRef.current.indexOf(lastInsertEvent);
|
||||||
|
if (stateIndex >= 0 && stateIndex < htmlStates.length) {
|
||||||
|
const previousState = htmlStates[stateIndex];
|
||||||
|
setQuestionHtml(previousState.question);
|
||||||
|
setAdditionalHtml(previousState.additional);
|
||||||
|
setWalkthroughHtml(previousState.walkthrough);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no previous insert event, revert to initial state
|
||||||
|
setQuestionHtml(tip.exercise?.question || '');
|
||||||
|
setAdditionalHtml(tip.exercise?.additional || '');
|
||||||
|
setWalkthroughHtml('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentEvents.forEach(currentEvent => {
|
||||||
|
if (currentEvent.type === 'text') {
|
||||||
const segment = segmentsRef.current[currentEvent.segmentIndex];
|
const segment = segmentsRef.current[currentEvent.segmentIndex];
|
||||||
const elapsedTime = currentTime - currentEvent.start;
|
const elapsedTime = currentTime - currentEvent.start;
|
||||||
const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length);
|
const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length);
|
||||||
|
|
||||||
const previousSegmentsHtml = segmentsRef.current
|
const previousSegmentsHtml = segmentsRef.current
|
||||||
.slice(0, currentEvent.segmentIndex)
|
.slice(0, currentEvent.segmentIndex)
|
||||||
.map((seg) => seg.html)
|
.map(seg => seg.html)
|
||||||
.join("");
|
.join('');
|
||||||
|
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const doc = parser.parseFromString(segment.html, "text/html");
|
const doc = parser.parseFromString(segment.html, 'text/html');
|
||||||
let wordCount = 0;
|
let wordCount = 0;
|
||||||
const walkTree = (node: Node, action: (node: Node) => void): boolean => {
|
const walkTree = (node: Node, action: (node: Node) => void): boolean => {
|
||||||
if (node.nodeType === Node.TEXT_NODE && node.textContent) {
|
if (node.nodeType === Node.TEXT_NODE && node.textContent) {
|
||||||
const words = node.textContent.split(/(\s+)/).filter((word) => word.length > 0);
|
const words = node.textContent.split(/(\s+)/).filter(word => word.length > 0);
|
||||||
if (wordCount + words.filter((w) => !/\s+/.test(w)).length <= wordsToShow) {
|
if (wordCount + words.filter(w => !/\s+/.test(w)).length <= wordsToShow) {
|
||||||
action(node.cloneNode(true));
|
action(node.cloneNode(true));
|
||||||
wordCount += words.filter((w) => !/\s+/.test(w)).length;
|
wordCount += words.filter(w => !/\s+/.test(w)).length;
|
||||||
} else {
|
} else {
|
||||||
const remainingWords = wordsToShow - wordCount;
|
const remainingWords = wordsToShow - wordCount;
|
||||||
const newTextContent = words.reduce(
|
const newTextContent = words.reduce((acc, word) => {
|
||||||
(acc, word) => {
|
if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) {
|
||||||
if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) {
|
acc.text += word;
|
||||||
acc.text += word;
|
acc.nonSpaceWords++;
|
||||||
acc.nonSpaceWords++;
|
} else if (/\s+/.test(word) || acc.nonSpaceWords < remainingWords) {
|
||||||
} else if (/\s+/.test(word) || acc.nonSpaceWords < remainingWords) {
|
acc.text += word;
|
||||||
acc.text += word;
|
}
|
||||||
}
|
return acc;
|
||||||
return acc;
|
}, { text: '', nonSpaceWords: 0 }).text;
|
||||||
},
|
|
||||||
{text: "", nonSpaceWords: 0},
|
|
||||||
).text;
|
|
||||||
const newNode = node.cloneNode(false);
|
const newNode = node.cloneNode(false);
|
||||||
newNode.textContent = newTextContent;
|
newNode.textContent = newTextContent;
|
||||||
action(newNode);
|
action(newNode);
|
||||||
@@ -133,38 +194,79 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
|||||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
const clone = node.cloneNode(false);
|
const clone = node.cloneNode(false);
|
||||||
action(clone);
|
action(clone);
|
||||||
Array.from(node.childNodes).some((child) => {
|
Array.from(node.childNodes).some(child => {
|
||||||
return walkTree(child, (childNode) => (clone as Node).appendChild(childNode));
|
return walkTree(child, childNode => (clone as Node).appendChild(childNode));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return wordCount >= wordsToShow;
|
return wordCount >= wordsToShow;
|
||||||
};
|
};
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
walkTree(doc.body, (node) => fragment.appendChild(node));
|
walkTree(doc.body, node => fragment.appendChild(node));
|
||||||
|
|
||||||
const serializer = new XMLSerializer();
|
const serializer = new XMLSerializer();
|
||||||
const currentSegmentHtml = Array.from(fragment.childNodes)
|
const currentSegmentHtml = Array.from(fragment.childNodes)
|
||||||
.map((node) => serializer.serializeToString(node))
|
.map(node => serializer.serializeToString(node))
|
||||||
.join("");
|
.join('');
|
||||||
const newHtml = previousSegmentsHtml + currentSegmentHtml;
|
const newHtml = previousSegmentsHtml + currentSegmentHtml;
|
||||||
|
|
||||||
setWalkthroughHtml(newHtml);
|
setWalkthroughHtml(newHtml);
|
||||||
setHighlightedPhrases([]);
|
setCurrentSegmentIndex(currentEvent.segmentIndex);
|
||||||
} else if (currentEvent.type === "highlight") {
|
setCurrentHighlightConfigs([]);
|
||||||
|
} else if (currentEvent.type === 'highlight') {
|
||||||
const newHtml = segmentsRef.current
|
const newHtml = segmentsRef.current
|
||||||
.slice(0, currentEvent.segmentIndex + 1)
|
.slice(0, currentEvent.segmentIndex + 1)
|
||||||
.map((seg) => seg.html)
|
.map(seg => seg.html)
|
||||||
.join("");
|
.join('');
|
||||||
setWalkthroughHtml(newHtml);
|
setWalkthroughHtml(newHtml);
|
||||||
setHighlightedPhrases(currentEvent.content || []);
|
setCurrentSegmentIndex(currentEvent.segmentIndex);
|
||||||
|
setCurrentHighlightConfigs(currentEvent.content as HighlightConfig[] || []);
|
||||||
|
} else if (currentEvent.type === 'insert') {
|
||||||
|
const insertConfigs = currentEvent.content as InsertHtmlConfig[];
|
||||||
|
insertConfigs.forEach(config => {
|
||||||
|
switch (config.target) {
|
||||||
|
case 'question':
|
||||||
|
setQuestionHtml(prevHtml => insertHtmlContent(prevHtml, config));
|
||||||
|
break;
|
||||||
|
case 'additional':
|
||||||
|
setAdditionalHtml(prevHtml => insertHtmlContent(prevHtml, config));
|
||||||
|
break;
|
||||||
|
case 'segment':
|
||||||
|
setWalkthroughHtml(prevHtml => insertHtmlContent(prevHtml, config));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
lastProcessedInsertTime.current = currentTime;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}, [currentTime]);
|
}, [currentTime, htmlStates, tip.exercise?.question, tip.exercise?.additional]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateText();
|
updateText();
|
||||||
}, [currentTime, updateText]);
|
}, [currentTime, updateText]);
|
||||||
|
|
||||||
|
const insertHtmlContent = (prevHtml: string, config: InsertHtmlConfig): string => {
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = prevHtml;
|
||||||
|
|
||||||
|
const targetElement = tempDiv.querySelector(`#${config.targetId}`);
|
||||||
|
if (targetElement) {
|
||||||
|
switch (config.position) {
|
||||||
|
case 'append':
|
||||||
|
targetElement.insertAdjacentHTML('beforeend', config.html);
|
||||||
|
break;
|
||||||
|
case 'prepend':
|
||||||
|
targetElement.insertAdjacentHTML('afterbegin', config.html);
|
||||||
|
break;
|
||||||
|
case 'replace':
|
||||||
|
targetElement.innerHTML = config.html;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tempDiv.innerHTML;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAutoPlaying) {
|
if (isAutoPlaying) {
|
||||||
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
|
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
|
||||||
@@ -219,62 +321,81 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (tip.standalone || !tip.exercise) {
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto">
|
|
||||||
<h1 className="text-xl font-bold text-red-600">The exercise for this tip is not available yet!</h1>
|
|
||||||
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4 mt-10">
|
|
||||||
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
|
|
||||||
<div className="text-gray-700" dangerouslySetInnerHTML={{__html: tip.tipHtml}} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto">
|
<div className="container mx-auto py-6">
|
||||||
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4">
|
<Tip category={tip.tipCategory} html={tip.tipHtml} />
|
||||||
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
|
{!tip.standalone && (
|
||||||
<div className="text-gray-700" dangerouslySetInnerHTML={{__html: tip.tipHtml}} />
|
<div className='flex flex-col space-y-4'>
|
||||||
</div>
|
<div className='flex flex-row items-center space-x-4 py-4'>
|
||||||
<div className="flex flex-col space-y-4">
|
<button
|
||||||
<div className="flex flex-row items-center space-x-4 py-4">
|
onClick={toggleAutoPlay}
|
||||||
<button
|
className="p-2 bg-blue-500 text-white rounded-full transition-colors duration-200 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
|
||||||
onClick={toggleAutoPlay}
|
aria-label={isAutoPlaying ? 'Pause' : 'Play'}
|
||||||
className="p-2 bg-blue-500 text-white rounded-full transition-colors duration-200 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
|
>
|
||||||
aria-label={isAutoPlaying ? "Pause" : "Play"}>
|
{isAutoPlaying ? (
|
||||||
{isAutoPlaying ? <FaRegCircleStop className="w-6 h-6" /> : <FaRegCirclePlay className="w-6 h-6" />}
|
<FaRegCircleStop className="w-6 h-6" />
|
||||||
</button>
|
) : (
|
||||||
<input
|
<FaRegCirclePlay className="w-6 h-6" />
|
||||||
type="range"
|
)}
|
||||||
min="0"
|
</button>
|
||||||
max={timelineRef.current.length > 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0}
|
<input
|
||||||
value={currentTime}
|
type="range"
|
||||||
onChange={handleSliderChange}
|
min="0"
|
||||||
onMouseDown={handleSliderMouseDown}
|
max={timelineRef.current.length > 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0}
|
||||||
onMouseUp={handleSliderMouseUp}
|
value={currentTime}
|
||||||
onTouchStart={handleSliderMouseDown}
|
onChange={handleSliderChange}
|
||||||
onTouchEnd={handleSliderMouseUp}
|
onMouseDown={handleSliderMouseDown}
|
||||||
className="flex-grow"
|
onMouseUp={handleSliderMouseUp}
|
||||||
/>
|
onTouchStart={handleSliderMouseDown}
|
||||||
</div>
|
onTouchEnd={handleSliderMouseUp}
|
||||||
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4">
|
className='flex-grow'
|
||||||
<div className="flex-1 bg-white p-6 rounded-lg shadow">
|
/>
|
||||||
{/*<h2 className="text-xl font-bold mb-4">Question</h2>*/}
|
|
||||||
<div className="mb-4" dangerouslySetInnerHTML={{__html: tip.exercise.question}} />
|
|
||||||
<HighlightContent html={tip.exercise.highlightable} highlightPhrases={highlightedPhrases} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 w-full'>
|
||||||
<div className="bg-gray-50 rounded-lg shadow">
|
<div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 w-full'>
|
||||||
<div className="p-6 space-y-4">
|
<div className='flex-1 bg-white p-6 rounded-lg shadow space-y-6'>
|
||||||
<animated.div dangerouslySetInnerHTML={{__html: walkthroughHtml}} />
|
<div className="container mx-auto px-4">
|
||||||
|
<div id="question-container" className="border p-6 rounded-lg shadow-md">
|
||||||
|
<HighlightContent
|
||||||
|
html={questionHtml}
|
||||||
|
highlightConfigs={currentHighlightConfigs}
|
||||||
|
contentType="question"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{tip.exercise?.additional && (<div className="container mx-auto px-4">
|
||||||
|
<div id="additional-container" className="border p-6 rounded-lg shadow-md">
|
||||||
|
<HighlightContent
|
||||||
|
html={additionalHtml}
|
||||||
|
highlightConfigs={currentHighlightConfigs}
|
||||||
|
contentType="additional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='flex-1'>
|
||||||
|
<div className='bg-gray-50 rounded-lg shadow'>
|
||||||
|
<div id="segment-container" className='p-6 space-y-4'>
|
||||||
|
<animated.div>
|
||||||
|
<HighlightContent
|
||||||
|
html={walkthroughHtml}
|
||||||
|
highlightConfigs={currentHighlightConfigs.filter(config =>
|
||||||
|
config.targets.includes('segment') || config.targets.includes('all')
|
||||||
|
)}
|
||||||
|
contentType="segment"
|
||||||
|
currentSegmentIndex={currentSegmentIndex}
|
||||||
|
/>
|
||||||
|
</animated.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ExerciseWalkthrough;
|
export default ExerciseWalkthrough;
|
||||||
201
src/components/TrainingContent/FormatTip.ts
Normal file
201
src/components/TrainingContent/FormatTip.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { ITrainingTip, WalkthroughConfigs } from "./TrainingInterfaces";
|
||||||
|
|
||||||
|
const colorOptions = [
|
||||||
|
'red', 'blue', 'green', 'purple', 'pink', 'indigo', 'teal', 'orange', 'cyan', 'emerald', 'sky', 'violet', 'fuchsia', 'rose', 'lime', 'slate'
|
||||||
|
]
|
||||||
|
|
||||||
|
const getRandomColors = (count: number) => {
|
||||||
|
const shuffled = [...colorOptions].sort(() => 0.5 - Math.random());
|
||||||
|
return shuffled.slice(0, count);
|
||||||
|
};
|
||||||
|
|
||||||
|
const classMap = {
|
||||||
|
"mainDiv": {
|
||||||
|
"tip": "flex-col gap-2",
|
||||||
|
"question": "flex-col gap-2",
|
||||||
|
"additional": "flex-col gap-8",
|
||||||
|
"segment": "p-4 rounded-lg mb-4 flex flex-col gap-2"
|
||||||
|
},
|
||||||
|
"h2": {
|
||||||
|
"tip": "mb-4 font-semibold text-lg",
|
||||||
|
"question": "text-lg font-semibold mb-4",
|
||||||
|
"additional": "text-2xl font-semibold mb-4",
|
||||||
|
"segment": "text-xl font-semibold"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setClass = (element: Element, style: string) => {
|
||||||
|
element.setAttribute('class', style)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// DON'T OVERRIDE DIV AND SPAN STYLES
|
||||||
|
const processHtml = (section: string, html: string, color: string) => {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(html, 'text/html');
|
||||||
|
|
||||||
|
const mainDiv = doc.body.firstElementChild;
|
||||||
|
if (mainDiv && mainDiv.tagName === 'DIV') {
|
||||||
|
if (section === "segment") {
|
||||||
|
setClass(mainDiv, `bg-${color}-100 ${classMap["mainDiv"][section]}`);
|
||||||
|
} else {
|
||||||
|
setClass(mainDiv, classMap["mainDiv"][section as keyof typeof classMap["mainDiv"]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.querySelectorAll('h1').forEach(e => {
|
||||||
|
if (section === "additional") {
|
||||||
|
setClass(e, 'text-4xl font-bold mb-6')
|
||||||
|
} else {
|
||||||
|
setClass(e, 'text-xl font-semibold mb-4');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('h2').forEach(e => {
|
||||||
|
setClass(e, classMap["h2"][section as keyof typeof classMap["h2"]])
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('h3').forEach(e => {
|
||||||
|
e.setAttribute('class', 'text-lg font-semibold mb-4')
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.querySelectorAll('p').forEach(e => {
|
||||||
|
if (section === "segment") {
|
||||||
|
setClass(e, 'text-gray-700 leading-relaxed')
|
||||||
|
} else {
|
||||||
|
setClass(e, 'mb-4');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('label').forEach(e => {
|
||||||
|
if (section === "additional") {
|
||||||
|
setClass(e, 'font-semibold');
|
||||||
|
} else {
|
||||||
|
setClass(e, 'min-w-[16px] mr-1 font-semibold');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('ul').forEach(e => {
|
||||||
|
const hasLabel = Array.from(e.querySelectorAll('li')).some(li => li.querySelector('label'));
|
||||||
|
if (hasLabel) {
|
||||||
|
e.setAttribute('class', 'list-none space-y-2');
|
||||||
|
} else {
|
||||||
|
e.setAttribute('class', `list-disc pl-5 space-y-2`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('ol').forEach(e => {
|
||||||
|
e.setAttribute('class', 'list-decimal pl-5 space-y-2');
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.querySelectorAll('hz-row').forEach(e => {
|
||||||
|
e.setAttribute('class', `flex flex-row items-center mb-4 gap-2`);
|
||||||
|
})
|
||||||
|
|
||||||
|
if (section === "segment") {
|
||||||
|
doc.querySelectorAll('b').forEach(e => {
|
||||||
|
e.setAttribute('class', `text-${color}-700`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.querySelectorAll('section').forEach(e => {
|
||||||
|
e.setAttribute('class', `mb-8`);
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('option-box').forEach(e => {
|
||||||
|
e.setAttribute('class', `flex justify-center min-w-[32px] min-h-6 bg-gray-200 rounded`);
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('option-box-grow').forEach(e => {
|
||||||
|
e.setAttribute('class', 'flex flex-grow ml-2 w-10 min-h-6 bg-gray-200 rounded px-4 py-2');
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.querySelectorAll('option-box-blank').forEach(e => {
|
||||||
|
e.setAttribute('class', 'min-w-[32px] min-h-[32px] border border-gray-300 text-center mr-3 flex justify-center items-center');
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.querySelectorAll('option-card').forEach(e => {
|
||||||
|
e.setAttribute('class', 'bg-gray-100 rounded-lg flex flex-col p-4')
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.querySelectorAll('footer').forEach(e => {
|
||||||
|
e.setAttribute('class', `flex flex-col gap-2 text-sm`);
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('single-line').forEach(e => {
|
||||||
|
e.setAttribute('class', `border-b border-black w-full h-4 inline-block`);
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.querySelectorAll('padded-line').forEach(e => {
|
||||||
|
e.setAttribute('class', `my-2 inline-block w-full`);
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.querySelectorAll('table').forEach(table => {
|
||||||
|
table.setAttribute('class', 'min-w-full bg-white border border-gray-300')
|
||||||
|
|
||||||
|
table.querySelectorAll('thead tr').forEach(tr => {
|
||||||
|
tr.setAttribute('class', 'bg-gray-100');
|
||||||
|
});
|
||||||
|
|
||||||
|
table.querySelectorAll('th').forEach(th => {
|
||||||
|
th.setAttribute('class', 'py-2 px-4 border-b font-semibold text-left');
|
||||||
|
});
|
||||||
|
|
||||||
|
table.querySelectorAll('tbody tr').forEach((tr, index) => {
|
||||||
|
if (index % 2 === 1) {
|
||||||
|
tr.setAttribute('class', 'bg-gray-50');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
table.querySelectorAll('td').forEach(td => {
|
||||||
|
if (td === td.parentElement?.firstElementChild) {
|
||||||
|
td.setAttribute('class', 'py-2 px-4 border-b font-medium');
|
||||||
|
} else {
|
||||||
|
td.setAttribute('class', 'py-2 px-4 border-b');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.querySelectorAll('blockquote').forEach(e => {
|
||||||
|
setClass(e, `flex w-full justify-center ${section === "segment" ? "" : "mb-4"}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.querySelectorAll('items-between').forEach(e => {
|
||||||
|
setClass(e, 'flex flex-row justify-between mb-4')
|
||||||
|
})
|
||||||
|
|
||||||
|
return doc.body.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTip = (tip: ITrainingTip): ITrainingTip => {
|
||||||
|
if (tip.exercise && tip.exercise.segments) {
|
||||||
|
const colors = getRandomColors(tip.exercise.segments.length);
|
||||||
|
|
||||||
|
const processedSegments: WalkthroughConfigs[] = tip.exercise.segments.map((segment, index) => ({
|
||||||
|
...segment,
|
||||||
|
html: processHtml("segment", segment.html, colors[index])
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: tip.id,
|
||||||
|
tipCategory: tip.tipCategory,
|
||||||
|
tipHtml: processHtml("tip", tip.tipHtml, ""),
|
||||||
|
standalone: tip.standalone,
|
||||||
|
exercise: {
|
||||||
|
question: processHtml("question", tip.exercise.question, ""),
|
||||||
|
additional: tip.exercise.additional ? processHtml("additional", tip.exercise.additional, "") : undefined,
|
||||||
|
segments: processedSegments
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: tip.id,
|
||||||
|
tipCategory: tip.tipCategory,
|
||||||
|
tipHtml: processHtml("tip", tip.tipHtml, ""),
|
||||||
|
standalone: tip.standalone,
|
||||||
|
exercise: undefined
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default formatTip;
|
||||||
83
src/components/TrainingContent/Tip.tsx
Normal file
83
src/components/TrainingContent/Tip.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { FaChessKnight, FaLink, FaPen } from 'react-icons/fa';
|
||||||
|
import { IoLanguage } from 'react-icons/io5';
|
||||||
|
import { MdOutlineCategory } from 'react-icons/md';
|
||||||
|
import { GiSkills } from 'react-icons/gi';
|
||||||
|
import { BiBookReader } from 'react-icons/bi';
|
||||||
|
|
||||||
|
type CategoryConfig = {
|
||||||
|
[key: string]: {
|
||||||
|
headerColor: string;
|
||||||
|
bodyColor: string;
|
||||||
|
textColor: string;
|
||||||
|
icon: any;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryConfig : CategoryConfig = {
|
||||||
|
'Strategy': {
|
||||||
|
headerColor: 'bg-yellow-400',
|
||||||
|
bodyColor: 'bg-yellow-100',
|
||||||
|
textColor: 'text-yellow-900',
|
||||||
|
icon: FaChessKnight
|
||||||
|
},
|
||||||
|
'Word Partners': {
|
||||||
|
headerColor: 'bg-purple-700',
|
||||||
|
bodyColor: 'bg-purple-200',
|
||||||
|
textColor: 'text-purple-900',
|
||||||
|
icon: MdOutlineCategory
|
||||||
|
},
|
||||||
|
'Word Link': {
|
||||||
|
headerColor: 'bg-green-600',
|
||||||
|
bodyColor: 'bg-green-100',
|
||||||
|
textColor: 'text-green-900',
|
||||||
|
icon: FaLink
|
||||||
|
},
|
||||||
|
'CT Focus': {
|
||||||
|
headerColor: 'bg-purple-700',
|
||||||
|
bodyColor: 'bg-purple-200',
|
||||||
|
textColor: 'text-purple-900',
|
||||||
|
icon: GiSkills
|
||||||
|
},
|
||||||
|
'Reading Skill': {
|
||||||
|
headerColor: 'bg-orange-200',
|
||||||
|
bodyColor: 'bg-orange-100',
|
||||||
|
textColor: 'text-orange-900',
|
||||||
|
icon: BiBookReader
|
||||||
|
},
|
||||||
|
'Language for Writing': {
|
||||||
|
headerColor: 'bg-orange-200',
|
||||||
|
bodyColor: 'bg-orange-100',
|
||||||
|
textColor: 'text-orange-900',
|
||||||
|
icon: IoLanguage
|
||||||
|
},
|
||||||
|
'Writing Skill': {
|
||||||
|
headerColor: 'bg-orange-200',
|
||||||
|
bodyColor: 'bg-orange-100',
|
||||||
|
textColor: 'text-orange-900',
|
||||||
|
icon: FaPen
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Tip: React.FC<{ category: string; html: string }> = ({ category, html }) => {
|
||||||
|
|
||||||
|
const { headerColor, bodyColor, textColor, icon: Icon } = useMemo(() =>
|
||||||
|
categoryConfig[category] || categoryConfig['Strategy'], [category]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg overflow-hidden shadow-md mb-4">
|
||||||
|
<div className={`px-4 py-3 ${headerColor}`}>
|
||||||
|
<h2 className="font-bold text-white text-xl flex items-center">
|
||||||
|
<Icon className="ml-2 mr-2" size={24} />
|
||||||
|
{category === "CT Focus" ? "Critical Thinking" : category}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className={`p-6 ${bodyColor}`}>
|
||||||
|
<p className={`text-lg ${textColor}`} dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Tip;
|
||||||
@@ -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;
|
||||||
@@ -28,7 +29,7 @@ export interface ITrainingTip {
|
|||||||
standalone: boolean;
|
standalone: boolean;
|
||||||
exercise?: {
|
exercise?: {
|
||||||
question: string;
|
question: string;
|
||||||
highlightable: string;
|
additional?: string;
|
||||||
segments: WalkthroughConfigs[]
|
segments: WalkthroughConfigs[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,16 +38,31 @@ export interface WalkthroughConfigs {
|
|||||||
html: string;
|
html: string;
|
||||||
wordDelay: number;
|
wordDelay: number;
|
||||||
holdDelay: number;
|
holdDelay: number;
|
||||||
highlight: string[];
|
highlight?: HighlightConfig[];
|
||||||
|
insertHTML?: InsertHtmlConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HighlightTarget = 'question' | 'additional' | 'segment' | 'all';
|
||||||
|
|
||||||
|
export interface HighlightConfig {
|
||||||
|
targets: HighlightTarget[];
|
||||||
|
phrases: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InsertHtmlConfig {
|
||||||
|
target: 'question' | 'additional' | 'segment';
|
||||||
|
targetId: string;
|
||||||
|
html: string;
|
||||||
|
position: 'append' | 'prepend' | 'replace';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface TimelineEvent {
|
export interface TimelineEvent {
|
||||||
type: 'text' | 'highlight';
|
type: 'text' | 'highlight' | 'insert';
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
segmentIndex: number;
|
segmentIndex: number;
|
||||||
content?: string[];
|
content?: HighlightConfig[] | InsertHtmlConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SegmentRef extends WalkthroughConfigs {
|
export interface SegmentRef extends WalkthroughConfigs {
|
||||||
|
|||||||
@@ -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,24 +466,35 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
type="tel"
|
type="tel"
|
||||||
name="phone"
|
name="phone"
|
||||||
label="Phone number"
|
label="Phone number"
|
||||||
onChange={() => null}
|
onChange={setPhone}
|
||||||
placeholder="Enter phone number"
|
placeholder="Enter phone number"
|
||||||
defaultValue={user.demographicInformation?.phone}
|
defaultValue={phone}
|
||||||
disabled
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{user.type === "student" && (
|
{user.type === "student" && (
|
||||||
<Input
|
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||||
type="text"
|
<Input
|
||||||
name="passport_id"
|
type="text"
|
||||||
label="Passport/National ID"
|
name="passport_id"
|
||||||
onChange={() => null}
|
label="Passport/National ID"
|
||||||
placeholder="Enter National ID or Passport number"
|
onChange={() => null}
|
||||||
value={user.type === "student" ? user.demographicInformation?.passport_id : undefined}
|
placeholder="Enter National ID or Passport number"
|
||||||
disabled
|
value={user.type === "student" ? user.demographicInformation?.passport_id : undefined}
|
||||||
required
|
disabled
|
||||||
/>
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="studentID"
|
||||||
|
label="Student ID"
|
||||||
|
onChange={setStudentID}
|
||||||
|
placeholder="Enter Student ID"
|
||||||
|
disabled={!checkAccess(loggedInUser, getTypesOfUser(["teacher", "agent", "student"]), permissions, "editStudent")}
|
||||||
|
value={studentID}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,91 +1,91 @@
|
|||||||
import { Type } from "@/interfaces/user";
|
import {Type} from "@/interfaces/user";
|
||||||
|
|
||||||
export const PERMISSIONS = {
|
export const PERMISSIONS = {
|
||||||
generateCode: {
|
generateCode: {
|
||||||
student: ["corporate", "developer", "admin", "mastercorporate"],
|
student: ["corporate", "developer", "admin", "mastercorporate"],
|
||||||
teacher: ["corporate", "developer", "admin", "mastercorporate"],
|
teacher: ["corporate", "developer", "admin", "mastercorporate"],
|
||||||
corporate: ["admin", "developer"],
|
corporate: ["admin", "developer"],
|
||||||
mastercorporate: ["admin", "developer"],
|
mastercorporate: ["admin", "developer"],
|
||||||
|
|
||||||
admin: ["developer", "admin"],
|
admin: ["developer", "admin"],
|
||||||
agent: ["developer", "admin"],
|
agent: ["developer", "admin"],
|
||||||
developer: ["developer"],
|
developer: ["developer"],
|
||||||
},
|
},
|
||||||
deleteUser: {
|
deleteUser: {
|
||||||
student: {
|
student: {
|
||||||
perm: "deleteStudent",
|
perm: "deleteStudent",
|
||||||
list: ["corporate", "developer", "admin", "mastercorporate"],
|
list: ["corporate", "developer", "admin", "mastercorporate"],
|
||||||
},
|
},
|
||||||
teacher: {
|
teacher: {
|
||||||
perm: "deleteTeacher",
|
perm: "deleteTeacher",
|
||||||
list: ["corporate", "developer", "admin", "mastercorporate"],
|
list: ["corporate", "developer", "admin", "mastercorporate"],
|
||||||
},
|
},
|
||||||
corporate: {
|
corporate: {
|
||||||
perm: "deleteCorporate",
|
perm: "deleteCorporate",
|
||||||
list: ["admin", "developer"],
|
list: ["admin", "developer"],
|
||||||
},
|
},
|
||||||
mastercorporate: {
|
mastercorporate: {
|
||||||
perm: undefined,
|
perm: undefined,
|
||||||
list: ["admin", "developer"],
|
list: ["admin", "developer"],
|
||||||
},
|
},
|
||||||
|
|
||||||
admin: {
|
admin: {
|
||||||
perm: "deleteAdmin",
|
perm: "deleteAdmin",
|
||||||
list: ["developer", "admin"],
|
list: ["developer", "admin"],
|
||||||
},
|
},
|
||||||
agent: {
|
agent: {
|
||||||
perm: "deleteCountryManager",
|
perm: "deleteCountryManager",
|
||||||
list: ["developer", "admin"],
|
list: ["developer", "admin"],
|
||||||
},
|
},
|
||||||
developer: {
|
developer: {
|
||||||
perm: undefined,
|
perm: undefined,
|
||||||
list: ["developer"],
|
list: ["developer"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
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,
|
||||||
list: ["admin", "developer"],
|
list: ["admin", "developer"],
|
||||||
},
|
},
|
||||||
|
|
||||||
admin: {
|
admin: {
|
||||||
perm: "editAdmin",
|
perm: "editAdmin",
|
||||||
list: ["developer", "admin"],
|
list: ["developer", "admin"],
|
||||||
},
|
},
|
||||||
|
|
||||||
agent: {
|
agent: {
|
||||||
perm: "editCountryManager",
|
perm: "editCountryManager",
|
||||||
list: ["developer", "admin"],
|
list: ["developer", "admin"],
|
||||||
},
|
},
|
||||||
developer: {
|
developer: {
|
||||||
perm: undefined,
|
perm: undefined,
|
||||||
list: ["developer"],
|
list: ["developer"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
updateExpiryDate: {
|
updateExpiryDate: {
|
||||||
student: ["developer", "admin"],
|
student: ["developer", "admin"],
|
||||||
teacher: ["developer", "admin"],
|
teacher: ["developer", "admin"],
|
||||||
corporate: ["admin", "developer"],
|
corporate: ["admin", "developer"],
|
||||||
mastercorporate: ["admin", "developer"],
|
mastercorporate: ["admin", "developer"],
|
||||||
|
|
||||||
admin: ["developer", "admin"],
|
admin: ["developer", "admin"],
|
||||||
agent: ["developer", "admin"],
|
agent: ["developer", "admin"],
|
||||||
developer: ["developer"],
|
developer: ["developer"],
|
||||||
},
|
},
|
||||||
examManagement: {
|
examManagement: {
|
||||||
delete: ["developer", "admin"],
|
delete: ["developer", "admin"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,8 +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 {users, reload, isLoading} = useUsers();
|
||||||
const {users, reload} = useUsers();
|
|
||||||
const {groups} = useGroups({});
|
const {groups} = useGroups({});
|
||||||
const {pending, done} = usePaymentStatusUsers();
|
const {pending, done} = usePaymentStatusUsers();
|
||||||
|
|
||||||
@@ -45,14 +44,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 +70,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 +99,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 +127,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 +149,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 +166,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 +194,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 +212,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 +235,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 +262,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>
|
||||||
@@ -279,41 +279,47 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
<section className="w-full grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 place-items-center items-center justify-between">
|
<section className="w-full grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 place-items-center items-center justify-between">
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsPersonFill}
|
Icon={BsPersonFill}
|
||||||
|
isLoading={isLoading}
|
||||||
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}
|
||||||
|
isLoading={isLoading}
|
||||||
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}
|
||||||
|
isLoading={isLoading}
|
||||||
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}
|
||||||
|
isLoading={isLoading}
|
||||||
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
|
||||||
Icon={BsGlobeCentralSouthAsia}
|
Icon={BsGlobeCentralSouthAsia}
|
||||||
|
isLoading={isLoading}
|
||||||
label="Countries"
|
label="Countries"
|
||||||
value={[...new Set(users.filter((x) => x.demographicInformation).map((x) => x.demographicInformation?.country))].length}
|
value={[...new Set(users.filter((x) => x.demographicInformation).map((x) => x.demographicInformation?.country))].length}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("inactiveStudents")}
|
onClick={() => router.push("/#inactiveStudents")}
|
||||||
Icon={BsPersonFill}
|
Icon={BsPersonFill}
|
||||||
|
isLoading={isLoading}
|
||||||
label="Inactive Students"
|
label="Inactive Students"
|
||||||
value={
|
value={
|
||||||
users.filter((x) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
|
users.filter((x) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
|
||||||
@@ -322,15 +328,17 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
color="rose"
|
color="rose"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("inactiveCountryManagers")}
|
onClick={() => router.push("/#inactiveCountryManagers")}
|
||||||
Icon={BsBriefcaseFill}
|
Icon={BsBriefcaseFill}
|
||||||
|
isLoading={isLoading}
|
||||||
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}
|
||||||
|
isLoading={isLoading}
|
||||||
label="Inactive Corporate"
|
label="Inactive Corporate"
|
||||||
value={
|
value={
|
||||||
users.filter((x) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
|
users.filter((x) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
|
||||||
@@ -338,10 +346,18 @@ 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}
|
Icon={BsCurrencyDollar}
|
||||||
|
isLoading={isLoading}
|
||||||
|
label="Payment Done"
|
||||||
|
value={done.length}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
onClick={() => router.push("/#paymentpending")}
|
||||||
|
Icon={BsCurrencyDollar}
|
||||||
|
isLoading={isLoading}
|
||||||
label="Pending Payment"
|
label="Pending Payment"
|
||||||
value={pending.length}
|
value={pending.length}
|
||||||
color="rose"
|
color="rose"
|
||||||
@@ -349,10 +365,17 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => router.push("https://cms.encoach.com/admin")}
|
onClick={() => router.push("https://cms.encoach.com/admin")}
|
||||||
Icon={BsLayoutSidebar}
|
Icon={BsLayoutSidebar}
|
||||||
|
isLoading={isLoading}
|
||||||
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}
|
||||||
|
isLoading={isLoading}
|
||||||
|
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 +621,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,28 +403,98 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div className="flex flex-col gap-4 w-full items-end">
|
|
||||||
|
{user.type !== "teacher" && (
|
||||||
|
<section className="w-full flex flex-col gap-3">
|
||||||
|
<span className="font-semibold">Teachers ({teachers.length} selected)</span>
|
||||||
|
<div className="flex gap-4 overflow-x-scroll scrollbar-hide">
|
||||||
|
{groups.map((g) => (
|
||||||
|
<button
|
||||||
|
key={g.id}
|
||||||
|
onClick={() => {
|
||||||
|
const groupStudentIds = users.filter((u) => g.participants.includes(u.id)).map((u) => u.id);
|
||||||
|
if (groupStudentIds.every((u) => teachers.includes(u))) {
|
||||||
|
setTeachers((prev) => prev.filter((a) => !groupStudentIds.includes(a)));
|
||||||
|
} else {
|
||||||
|
setTeachers((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
users.filter((u) => g.participants.includes(u.id)).every((u) => teachers.includes(u.id)) &&
|
||||||
|
"!bg-mti-purple-light !text-white",
|
||||||
|
)}>
|
||||||
|
{g.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap -md:justify-center gap-4">
|
||||||
|
{userTeachers.map((user) => (
|
||||||
|
<div
|
||||||
|
onClick={() => toggleTeacher(user)}
|
||||||
|
className={clsx(
|
||||||
|
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
|
||||||
|
"transition ease-in-out duration-300",
|
||||||
|
teachers.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum",
|
||||||
|
)}
|
||||||
|
key={user.id}>
|
||||||
|
<span className="flex flex-col gap-0 justify-center">
|
||||||
|
<span className="font-semibold">{user.name}</span>
|
||||||
|
<span className="text-sm opacity-80">{user.email}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-mti-black/80 text-sm whitespace-pre-wrap mt-2">
|
||||||
|
Groups:{" "}
|
||||||
|
{groups
|
||||||
|
.filter((g) => g.participants.includes(user.id))
|
||||||
|
.map((g) => g.name)
|
||||||
|
.join(", ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-4 w-full items-end">
|
||||||
<Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
|
<Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
|
||||||
Full length exams
|
Full length exams
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
|
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
|
||||||
Generate different exams
|
Generate different exams
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
|
<Checkbox isChecked={released} onChange={() => setReleased((d) => !d)}>
|
||||||
|
Auto release results
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox isChecked={autoStart} onChange={() => setAutostart((d) => !d)}>
|
||||||
|
Auto start exam
|
||||||
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 w-full justify-end">
|
<div className="flex gap-4 w-full justify-end">
|
||||||
<Button className="w-full max-w-[200px]" variant="outline" onClick={cancelCreation} disabled={isLoading} isLoading={isLoading}>
|
<Button className="w-full max-w-[200px]" variant="outline" onClick={cancelCreation} disabled={isLoading} isLoading={isLoading}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
{assignment && (
|
{assignment && (
|
||||||
<Button
|
<>
|
||||||
className="w-full max-w-[200px]"
|
<Button
|
||||||
color="red"
|
className="w-full max-w-[200px]"
|
||||||
variant="outline"
|
color="green"
|
||||||
onClick={deleteAssignment}
|
variant="outline"
|
||||||
disabled={isLoading}
|
onClick={startAssignment}
|
||||||
isLoading={isLoading}>
|
disabled={isLoading || moment().isAfter(startDate)}
|
||||||
Delete
|
isLoading={isLoading}>
|
||||||
</Button>
|
Start
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
color="red"
|
||||||
|
variant="outline"
|
||||||
|
onClick={deleteAssignment}
|
||||||
|
disabled={isLoading}
|
||||||
|
isLoading={isLoading}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
disabled={
|
disabled={
|
||||||
|
|||||||
@@ -2,310 +2,433 @@ import Button from "@/components/Low/Button";
|
|||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {Module} from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import { Assignment } from "@/interfaces/results";
|
||||||
import {Stat, User} from "@/interfaces/user";
|
import { Stat, User } from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {getExamById} from "@/utils/exams";
|
import { getExamById } from "@/utils/exams";
|
||||||
import {sortByModule} from "@/utils/moduleUtils";
|
import { sortByModule } from "@/utils/moduleUtils";
|
||||||
import {calculateBandScore} from "@/utils/score";
|
import { calculateBandScore } from "@/utils/score";
|
||||||
import {convertToUserSolutions} from "@/utils/stats";
|
import { convertToUserSolutions } from "@/utils/stats";
|
||||||
import {getUserName} from "@/utils/users";
|
import { getUserName } from "@/utils/users";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize, uniqBy} from "lodash";
|
import { capitalize, uniqBy } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
import {
|
||||||
import {toast} from "react-toastify";
|
BsBook,
|
||||||
|
BsClipboard,
|
||||||
|
BsHeadphones,
|
||||||
|
BsMegaphone,
|
||||||
|
BsPen,
|
||||||
|
} from "react-icons/bs";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { futureAssignmentFilter } from "@/utils/assignments";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
assignment?: Assignment;
|
assignment?: Assignment;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
||||||
const {users} = useUsers();
|
const { users } = useUsers();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
const setExams = useExamStore((state) => state.setExams);
|
||||||
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
||||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||||
|
|
||||||
const deleteAssignment = async () => {
|
const deleteAssignment = async () => {
|
||||||
if (!confirm("Are you sure you want to delete this assignment?")) return;
|
if (!confirm("Are you sure you want to delete this assignment?")) return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.delete(`/api/assignments/${assignment?.id}`)
|
.delete(`/api/assignments/${assignment?.id}`)
|
||||||
.then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`))
|
.then(() =>
|
||||||
.catch(() => toast.error("Something went wrong, please try again later."))
|
toast.success(
|
||||||
.finally(onClose);
|
`Successfully deleted the assignment "${assignment?.name}".`
|
||||||
};
|
)
|
||||||
|
)
|
||||||
|
.catch(() => toast.error("Something went wrong, please try again later."))
|
||||||
|
.finally(onClose);
|
||||||
|
};
|
||||||
|
|
||||||
const formatTimestamp = (timestamp: string) => {
|
const startAssignment = () => {
|
||||||
const date = moment(parseInt(timestamp));
|
if (assignment) {
|
||||||
const formatter = "YYYY/MM/DD - HH:mm";
|
axios
|
||||||
|
.post(`/api/assignments/${assignment.id}/start`)
|
||||||
|
.then(() => {
|
||||||
|
toast.success(
|
||||||
|
`The assignment "${assignment.name}" has been started successfully!`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.log(e);
|
||||||
|
toast.error("Something went wrong, please try again later!");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return date.format(formatter);
|
const formatTimestamp = (timestamp: string) => {
|
||||||
};
|
const date = moment(parseInt(timestamp));
|
||||||
|
const formatter = "YYYY/MM/DD - HH:mm";
|
||||||
|
|
||||||
const calculateAverageModuleScore = (module: Module) => {
|
return date.format(formatter);
|
||||||
if (!assignment) return -1;
|
};
|
||||||
|
|
||||||
const resultModuleBandScores = assignment.results.map((r) => {
|
const calculateAverageModuleScore = (module: Module) => {
|
||||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
if (!assignment) return -1;
|
||||||
|
|
||||||
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
const resultModuleBandScores = assignment.results.map((r) => {
|
||||||
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||||
return calculateBandScore(correct, total, module, r.type);
|
|
||||||
});
|
|
||||||
|
|
||||||
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
|
const correct = moduleStats.reduce(
|
||||||
};
|
(acc, curr) => acc + curr.score.correct,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const total = moduleStats.reduce(
|
||||||
|
(acc, curr) => acc + curr.score.total,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return calculateBandScore(correct, total, module, r.type);
|
||||||
|
});
|
||||||
|
|
||||||
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
return resultModuleBandScores.length === 0
|
||||||
const scores: {
|
? -1
|
||||||
[key in Module]: {total: number; missing: number; correct: number};
|
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
|
||||||
} = {
|
assignment.results.length;
|
||||||
reading: {
|
};
|
||||||
total: 0,
|
|
||||||
correct: 0,
|
|
||||||
missing: 0,
|
|
||||||
},
|
|
||||||
listening: {
|
|
||||||
total: 0,
|
|
||||||
correct: 0,
|
|
||||||
missing: 0,
|
|
||||||
},
|
|
||||||
writing: {
|
|
||||||
total: 0,
|
|
||||||
correct: 0,
|
|
||||||
missing: 0,
|
|
||||||
},
|
|
||||||
speaking: {
|
|
||||||
total: 0,
|
|
||||||
correct: 0,
|
|
||||||
missing: 0,
|
|
||||||
},
|
|
||||||
level: {
|
|
||||||
total: 0,
|
|
||||||
correct: 0,
|
|
||||||
missing: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
stats.forEach((x) => {
|
const aggregateScoresByModule = (
|
||||||
scores[x.module!] = {
|
stats: Stat[]
|
||||||
total: scores[x.module!].total + x.score.total,
|
): { module: Module; total: number; missing: number; correct: number }[] => {
|
||||||
correct: scores[x.module!].correct + x.score.correct,
|
const scores: {
|
||||||
missing: scores[x.module!].missing + x.score.missing,
|
[key in Module]: { total: number; missing: number; correct: number };
|
||||||
};
|
} = {
|
||||||
});
|
reading: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
listening: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
writing: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
speaking: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
level: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
return Object.keys(scores)
|
stats.forEach((x) => {
|
||||||
.filter((x) => scores[x as Module].total > 0)
|
scores[x.module!] = {
|
||||||
.map((x) => ({module: x as Module, ...scores[x as Module]}));
|
total: scores[x.module!].total + x.score.total,
|
||||||
};
|
correct: scores[x.module!].correct + x.score.correct,
|
||||||
|
missing: scores[x.module!].missing + x.score.missing,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
|
return Object.keys(scores)
|
||||||
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
.filter((x) => scores[x as Module].total > 0)
|
||||||
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
||||||
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
};
|
||||||
|
|
||||||
const aggregatedLevels = aggregatedScores.map((x) => ({
|
const customContent = (
|
||||||
module: x.module,
|
stats: Stat[],
|
||||||
level: calculateBandScore(x.correct, x.total, x.module, focus),
|
user: string,
|
||||||
}));
|
focus: "academic" | "general"
|
||||||
|
) => {
|
||||||
|
const correct = stats.reduce(
|
||||||
|
(accumulator, current) => accumulator + current.score.correct,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const total = stats.reduce(
|
||||||
|
(accumulator, current) => accumulator + current.score.total,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const aggregatedScores = aggregateScoresByModule(stats).filter(
|
||||||
|
(x) => x.total > 0
|
||||||
|
);
|
||||||
|
|
||||||
const timeSpent = stats[0].timeSpent;
|
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||||
|
module: x.module,
|
||||||
|
level: calculateBandScore(x.correct, x.total, x.module, focus),
|
||||||
|
}));
|
||||||
|
|
||||||
const selectExam = () => {
|
const timeSpent = stats[0].timeSpent;
|
||||||
const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
|
|
||||||
|
|
||||||
Promise.all(examPromises).then((exams) => {
|
const selectExam = () => {
|
||||||
if (exams.every((x) => !!x)) {
|
const examPromises = uniqBy(stats, "exam").map((stat) =>
|
||||||
setUserSolutions(convertToUserSolutions(stats));
|
getExamById(stat.module, stat.exam)
|
||||||
setShowSolutions(true);
|
);
|
||||||
setExams(exams.map((x) => x!).sort(sortByModule));
|
|
||||||
setSelectedModules(
|
|
||||||
exams
|
|
||||||
.map((x) => x!)
|
|
||||||
.sort(sortByModule)
|
|
||||||
.map((x) => x!.module),
|
|
||||||
);
|
|
||||||
router.push("/exercises");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const content = (
|
Promise.all(examPromises).then((exams) => {
|
||||||
<>
|
if (exams.every((x) => !!x)) {
|
||||||
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
|
setUserSolutions(convertToUserSolutions(stats));
|
||||||
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
|
setShowSolutions(true);
|
||||||
<span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
|
setExams(exams.map((x) => x!).sort(sortByModule));
|
||||||
{timeSpent && (
|
setSelectedModules(
|
||||||
<>
|
exams
|
||||||
<span className="md:hidden 2xl:flex">• </span>
|
.map((x) => x!)
|
||||||
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
|
.sort(sortByModule)
|
||||||
</>
|
.map((x) => x!.module)
|
||||||
)}
|
);
|
||||||
</div>
|
router.push("/exercises");
|
||||||
<span
|
}
|
||||||
className={clsx(
|
});
|
||||||
correct / total >= 0.7 && "text-mti-purple",
|
};
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
|
||||||
correct / total < 0.3 && "text-mti-rose",
|
|
||||||
)}>
|
|
||||||
Level{" "}
|
|
||||||
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-1">
|
const content = (
|
||||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
<>
|
||||||
{aggregatedLevels.map(({module, level}) => (
|
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
|
||||||
<div
|
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
|
||||||
key={module}
|
<span className="font-medium">
|
||||||
className={clsx(
|
{formatTimestamp(stats[0].date.toString())}
|
||||||
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
</span>
|
||||||
module === "reading" && "bg-ielts-reading",
|
{timeSpent && (
|
||||||
module === "listening" && "bg-ielts-listening",
|
<>
|
||||||
module === "writing" && "bg-ielts-writing",
|
<span className="md:hidden 2xl:flex">• </span>
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
<span className="text-sm">
|
||||||
module === "level" && "bg-ielts-level",
|
{Math.floor(timeSpent / 60)} minutes
|
||||||
)}>
|
</span>
|
||||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
</>
|
||||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
)}
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
</div>
|
||||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
<span
|
||||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
className={clsx(
|
||||||
<span className="text-sm">{level.toFixed(1)}</span>
|
correct / total >= 0.7 && "text-mti-purple",
|
||||||
</div>
|
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||||
))}
|
correct / total < 0.3 && "text-mti-rose"
|
||||||
</div>
|
)}
|
||||||
</div>
|
>
|
||||||
</>
|
Level{" "}
|
||||||
);
|
{(
|
||||||
|
aggregatedLevels.reduce(
|
||||||
|
(accumulator, current) => accumulator + current.level,
|
||||||
|
0
|
||||||
|
) / aggregatedLevels.length
|
||||||
|
).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
return (
|
<div className="flex w-full flex-col gap-1">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||||
<span>
|
{aggregatedLevels.map(({ module, level }) => (
|
||||||
{(() => {
|
<div
|
||||||
const student = users.find((u) => u.id === user);
|
key={module}
|
||||||
return `${student?.name} (${student?.email})`;
|
className={clsx(
|
||||||
})()}
|
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||||
</span>
|
module === "reading" && "bg-ielts-reading",
|
||||||
<div
|
module === "listening" && "bg-ielts-listening",
|
||||||
key={user}
|
module === "writing" && "bg-ielts-writing",
|
||||||
className={clsx(
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
|
module === "level" && "bg-ielts-level"
|
||||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
)}
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
>
|
||||||
correct / total < 0.3 && "hover:border-mti-rose",
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
)}
|
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||||
onClick={selectExam}
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
role="button">
|
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||||
{content}
|
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||||
</div>
|
<span className="text-sm">{level.toFixed(1)}</span>
|
||||||
<div
|
</div>
|
||||||
key={user}
|
))}
|
||||||
className={clsx(
|
</div>
|
||||||
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
|
</div>
|
||||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
</>
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
);
|
||||||
correct / total < 0.3 && "hover:border-mti-rose",
|
|
||||||
)}
|
|
||||||
data-tip="Your screen size is too small to view previous exams."
|
|
||||||
role="button">
|
|
||||||
{content}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
|
<div className="flex flex-col gap-2">
|
||||||
<div className="mt-4 flex w-full flex-col gap-4">
|
<span>
|
||||||
<ProgressBar
|
{(() => {
|
||||||
color="purple"
|
const student = users.find((u) => u.id === user);
|
||||||
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
return `${student?.name} (${student?.email})`;
|
||||||
className="h-6"
|
})()}
|
||||||
textClassName={
|
</span>
|
||||||
(assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"
|
<div
|
||||||
}
|
key={user}
|
||||||
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100}
|
className={clsx(
|
||||||
/>
|
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
|
||||||
<div className="flex items-start gap-8">
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
<div className="flex flex-col gap-2">
|
correct / total >= 0.3 &&
|
||||||
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
|
correct / total < 0.7 &&
|
||||||
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
|
"hover:border-mti-red",
|
||||||
</div>
|
correct / total < 0.3 && "hover:border-mti-rose"
|
||||||
<div className="flex flex-col gap-2">
|
)}
|
||||||
<span>
|
onClick={selectExam}
|
||||||
Assignees:{" "}
|
role="button"
|
||||||
{users
|
>
|
||||||
.filter((u) => assignment?.assignees.includes(u.id))
|
{content}
|
||||||
.map((u) => `${u.name} (${u.email})`)
|
</div>
|
||||||
.join(", ")}
|
<div
|
||||||
</span>
|
key={user}
|
||||||
<span>Assigner: {getUserName(users.find((x) => x.id === assignment?.assigner))}</span>
|
className={clsx(
|
||||||
</div>
|
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
|
||||||
</div>
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
<div className="flex flex-col gap-2">
|
correct / total >= 0.3 &&
|
||||||
<span className="text-xl font-bold">Average Scores</span>
|
correct / total < 0.7 &&
|
||||||
<div className="-md:mt-2 flex w-full items-center gap-4">
|
"hover:border-mti-red",
|
||||||
{assignment &&
|
correct / total < 0.3 && "hover:border-mti-rose"
|
||||||
uniqBy(assignment.exams, (x) => x.module).map(({module}) => (
|
)}
|
||||||
<div
|
data-tip="Your screen size is too small to view previous exams."
|
||||||
data-tip={capitalize(module)}
|
role="button"
|
||||||
key={module}
|
>
|
||||||
className={clsx(
|
{content}
|
||||||
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
</div>
|
||||||
module === "reading" && "bg-ielts-reading",
|
</div>
|
||||||
module === "listening" && "bg-ielts-listening",
|
);
|
||||||
module === "writing" && "bg-ielts-writing",
|
};
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
|
||||||
module === "level" && "bg-ielts-level",
|
|
||||||
)}>
|
|
||||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
|
||||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
|
||||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
|
||||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
|
||||||
{calculateAverageModuleScore(module) > -1 && (
|
|
||||||
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span className="text-xl font-bold">
|
|
||||||
Results ({assignment?.results.length}/{assignment?.assignees.length})
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
{assignment && assignment?.results.length > 0 && (
|
|
||||||
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
|
|
||||||
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{assignment && assignment?.results.length === 0 && <span className="ml-1 font-semibold">No results yet...</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-4 w-full items-center justify-end">
|
const shouldRenderStart = () => {
|
||||||
{assignment && (assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && (
|
if (assignment) {
|
||||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={deleteAssignment}>
|
if (futureAssignmentFilter(assignment)) {
|
||||||
Delete
|
return true;
|
||||||
</Button>
|
}
|
||||||
)}
|
}
|
||||||
<Button onClick={onClose} className="w-full max-w-[200px]">
|
|
||||||
Close
|
return false;
|
||||||
</Button>
|
};
|
||||||
</div>
|
|
||||||
</div>
|
return (
|
||||||
</Modal>
|
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
|
||||||
);
|
<div className="mt-4 flex w-full flex-col gap-4">
|
||||||
|
<ProgressBar
|
||||||
|
color="purple"
|
||||||
|
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
||||||
|
className="h-6"
|
||||||
|
textClassName={
|
||||||
|
(assignment?.results.length || 0) /
|
||||||
|
(assignment?.assignees.length || 1) <
|
||||||
|
0.5
|
||||||
|
? "!text-mti-gray-dim font-light"
|
||||||
|
: "text-white"
|
||||||
|
}
|
||||||
|
percentage={
|
||||||
|
((assignment?.results.length || 0) /
|
||||||
|
(assignment?.assignees.length || 1)) *
|
||||||
|
100
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex items-start gap-8">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span>
|
||||||
|
Start Date:{" "}
|
||||||
|
{moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span>
|
||||||
|
Assignees:{" "}
|
||||||
|
{users
|
||||||
|
.filter((u) => assignment?.assignees.includes(u.id))
|
||||||
|
.map((u) => `${u.name} (${u.email})`)
|
||||||
|
.join(", ")}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Assigner:{" "}
|
||||||
|
{getUserName(users.find((x) => x.id === assignment?.assigner))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-xl font-bold">Average Scores</span>
|
||||||
|
<div className="-md:mt-2 flex w-full items-center gap-4">
|
||||||
|
{assignment &&
|
||||||
|
uniqBy(assignment.exams, (x) => x.module).map(({ module }) => (
|
||||||
|
<div
|
||||||
|
data-tip={capitalize(module)}
|
||||||
|
key={module}
|
||||||
|
className={clsx(
|
||||||
|
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||||
|
module === "reading" && "bg-ielts-reading",
|
||||||
|
module === "listening" && "bg-ielts-listening",
|
||||||
|
module === "writing" && "bg-ielts-writing",
|
||||||
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
|
module === "level" && "bg-ielts-level"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
|
{module === "listening" && (
|
||||||
|
<BsHeadphones className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
|
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||||
|
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||||
|
{calculateAverageModuleScore(module) > -1 && (
|
||||||
|
<span className="text-sm">
|
||||||
|
{calculateAverageModuleScore(module).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-xl font-bold">
|
||||||
|
Results ({assignment?.results.length}/{assignment?.assignees.length}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
{assignment && assignment?.results.length > 0 && (
|
||||||
|
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
|
||||||
|
{assignment.results.map((r) =>
|
||||||
|
customContent(r.stats, r.user, r.type)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{assignment && assignment?.results.length === 0 && (
|
||||||
|
<span className="ml-1 font-semibold">No results yet...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 w-full items-center justify-end">
|
||||||
|
{assignment &&
|
||||||
|
(assignment.results.length === assignment.assignees.length ||
|
||||||
|
moment().isAfter(moment(assignment.endDate))) && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={deleteAssignment}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{/** if the assignment is not deemed as active yet, display start */}
|
||||||
|
{shouldRenderStart() && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="green"
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={startAssignment}
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={onClose} className="w-full max-w-[200px]">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,647 +0,0 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
|
||||||
import Modal from "@/components/Modal";
|
|
||||||
import useStats from "@/hooks/useStats";
|
|
||||||
import useUsers from "@/hooks/useUsers";
|
|
||||||
import {CorporateUser, Group, Stat, User} from "@/interfaces/user";
|
|
||||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
|
||||||
import {dateSorter} from "@/utils";
|
|
||||||
import moment from "moment";
|
|
||||||
import {useEffect, useState} from "react";
|
|
||||||
import {
|
|
||||||
BsArrowLeft,
|
|
||||||
BsClipboard2Data,
|
|
||||||
BsClipboard2DataFill,
|
|
||||||
BsClock,
|
|
||||||
BsGlobeCentralSouthAsia,
|
|
||||||
BsPaperclip,
|
|
||||||
BsPerson,
|
|
||||||
BsPersonAdd,
|
|
||||||
BsPersonFill,
|
|
||||||
BsPersonFillGear,
|
|
||||||
BsPersonGear,
|
|
||||||
BsPencilSquare,
|
|
||||||
BsPersonBadge,
|
|
||||||
BsPersonCheck,
|
|
||||||
BsPeople,
|
|
||||||
BsArrowRepeat,
|
|
||||||
BsPlus,
|
|
||||||
BsEnvelopePaper,
|
|
||||||
} from "react-icons/bs";
|
|
||||||
import UserCard from "@/components/UserCard";
|
|
||||||
import useGroups from "@/hooks/useGroups";
|
|
||||||
import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score";
|
|
||||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
|
||||||
import {Module} from "@/interfaces";
|
|
||||||
import {groupByExam} from "@/utils/stats";
|
|
||||||
import IconCard from "./IconCard";
|
|
||||||
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
|
||||||
import useFilterStore from "@/stores/listFilterStore";
|
|
||||||
import {useRouter} from "next/router";
|
|
||||||
import useCodes from "@/hooks/useCodes";
|
|
||||||
import {getUserCorporate} from "@/utils/groups";
|
|
||||||
import useAssignments from "@/hooks/useAssignments";
|
|
||||||
import {Assignment} from "@/interfaces/results";
|
|
||||||
import AssignmentView from "./AssignmentView";
|
|
||||||
import AssignmentCreator from "./AssignmentCreator";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import AssignmentCard from "./AssignmentCard";
|
|
||||||
import {createColumnHelper} from "@tanstack/react-table";
|
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
|
||||||
import List from "@/components/List";
|
|
||||||
import {getUserCompanyName} from "@/resources/user";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
user: CorporateUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
type StudentPerformanceItem = User & {corporateName: string; group: string};
|
|
||||||
const StudentPerformanceList = ({items, stats, users}: {items: StudentPerformanceItem[]; stats: Stat[]; users: User[]}) => {
|
|
||||||
const [isShowingAmount, setIsShowingAmount] = useState(false);
|
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<StudentPerformanceItem>();
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
columnHelper.accessor("name", {
|
|
||||||
header: "Student Name",
|
|
||||||
cell: (info) => info.getValue(),
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("email", {
|
|
||||||
header: "E-mail",
|
|
||||||
cell: (info) => info.getValue(),
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("demographicInformation.passport_id", {
|
|
||||||
header: "ID",
|
|
||||||
cell: (info) => info.getValue() || "N/A",
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("group", {
|
|
||||||
header: "Group",
|
|
||||||
cell: (info) => info.getValue(),
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("corporateName", {
|
|
||||||
header: "Corporate",
|
|
||||||
cell: (info) => info.getValue() || "N/A",
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("levels.reading", {
|
|
||||||
header: "Reading",
|
|
||||||
cell: (info) =>
|
|
||||||
!isShowingAmount
|
|
||||||
? info.getValue() || 0
|
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("levels.listening", {
|
|
||||||
header: "Listening",
|
|
||||||
cell: (info) =>
|
|
||||||
!isShowingAmount
|
|
||||||
? info.getValue() || 0
|
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("levels.writing", {
|
|
||||||
header: "Writing",
|
|
||||||
cell: (info) =>
|
|
||||||
!isShowingAmount
|
|
||||||
? info.getValue() || 0
|
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("levels.speaking", {
|
|
||||||
header: "Speaking",
|
|
||||||
cell: (info) =>
|
|
||||||
!isShowingAmount
|
|
||||||
? info.getValue() || 0
|
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("levels.level", {
|
|
||||||
header: "Level",
|
|
||||||
cell: (info) =>
|
|
||||||
!isShowingAmount
|
|
||||||
? info.getValue() || 0
|
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("levels", {
|
|
||||||
id: "overall_level",
|
|
||||||
header: "Overall",
|
|
||||||
cell: (info) =>
|
|
||||||
!isShowingAmount
|
|
||||||
? averageLevelCalculator(
|
|
||||||
users,
|
|
||||||
stats.filter((x) => x.user === info.row.original.id),
|
|
||||||
).toFixed(1)
|
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4 w-full h-full">
|
|
||||||
<Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}>
|
|
||||||
Show Utilization
|
|
||||||
</Checkbox>
|
|
||||||
<List<StudentPerformanceItem>
|
|
||||||
data={items.sort(
|
|
||||||
(a, b) =>
|
|
||||||
averageLevelCalculator(
|
|
||||||
users,
|
|
||||||
stats.filter((x) => x.user === b.id),
|
|
||||||
) -
|
|
||||||
averageLevelCalculator(
|
|
||||||
users,
|
|
||||||
stats.filter((x) => x.user === a.id),
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
columns={columns}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CorporateDashboard({user}: Props) {
|
|
||||||
const [page, setPage] = useState("");
|
|
||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
|
||||||
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 {users, reload, isLoading} = useUsers();
|
|
||||||
const {codes} = useCodes(user.id);
|
|
||||||
const {groups} = useGroups({admin: user.id});
|
|
||||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
|
|
||||||
|
|
||||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setShowModal(!!selectedUser && page === "");
|
|
||||||
}, [selectedUser, page]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const relatedGroups = groups.filter((x) => x.name === "Students" || x.name === "Teachers" || x.name === "Corporate");
|
|
||||||
const usersInGroups = relatedGroups.map((x) => x.participants).flat();
|
|
||||||
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 UserDisplay = (displayUser: User) => (
|
|
||||||
<div
|
|
||||||
onClick={() => setSelectedUser(displayUser)}
|
|
||||||
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
|
||||||
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
|
||||||
<div className="flex flex-col gap-1 items-start">
|
|
||||||
<span>{displayUser.name}</span>
|
|
||||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const 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 filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div
|
|
||||||
onClick={() => setPage("")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold">Groups ({groups.filter(filter).length})</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<GroupList user={user} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 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))?.name || "N/A",
|
|
||||||
corporateName: getUserCompanyName(u, users, groups),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<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={reload}
|
|
||||||
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>
|
|
||||||
<StudentPerformanceList items={students} stats={stats} users={users} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const averageLevelCalculator = (studentStats: Stat[]) => {
|
|
||||||
const formattedStats = studentStats
|
|
||||||
.map((s) => ({
|
|
||||||
focus: users.find((u) => u.id === s.user)?.focus,
|
|
||||||
score: s.score,
|
|
||||||
module: s.module,
|
|
||||||
}))
|
|
||||||
.filter((f) => !!f.focus);
|
|
||||||
const bandScores = formattedStats.map((s) => ({
|
|
||||||
module: s.module,
|
|
||||||
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const levels: {[key in Module]: number} = {
|
|
||||||
reading: 0,
|
|
||||||
listening: 0,
|
|
||||||
writing: 0,
|
|
||||||
speaking: 0,
|
|
||||||
level: 0,
|
|
||||||
};
|
|
||||||
bandScores.forEach((b) => (levels[b.module] += b.level));
|
|
||||||
|
|
||||||
return calculateAverageLevel(levels);
|
|
||||||
};
|
|
||||||
|
|
||||||
const DefaultDashboard = () => (
|
|
||||||
<>
|
|
||||||
{corporateUserToShow && (
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<section className="grid grid-cols-5 -md:grid-cols-2 gap-4 text-center">
|
|
||||||
<IconCard
|
|
||||||
onClick={() => setPage("students")}
|
|
||||||
Icon={BsPersonFill}
|
|
||||||
label="Students"
|
|
||||||
value={users.filter(studentFilter).length}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
onClick={() => setPage("teachers")}
|
|
||||||
Icon={BsPencilSquare}
|
|
||||||
label="Teachers"
|
|
||||||
value={users.filter(teacherFilter).length}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsClipboard2Data}
|
|
||||||
label="Exams Performed"
|
|
||||||
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsPaperclip}
|
|
||||||
label="Average Level"
|
|
||||||
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard onClick={() => setPage("groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
|
|
||||||
<IconCard
|
|
||||||
Icon={BsPersonCheck}
|
|
||||||
label="User Balance"
|
|
||||||
value={`${userBalance}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsClock}
|
|
||||||
label="Expiration Date"
|
|
||||||
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
|
|
||||||
color="rose"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsPersonFillGear}
|
|
||||||
label="Student Performance"
|
|
||||||
value={users.filter(studentFilter).length}
|
|
||||||
color="purple"
|
|
||||||
onClick={() => setPage("studentsPerformance")}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
disabled={isAssignmentsLoading}
|
|
||||||
onClick={() => setPage("assignments")}
|
|
||||||
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
|
||||||
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
|
||||||
<span className="flex flex-col gap-1 items-center text-xl">
|
|
||||||
<span className="text-lg">Assignments</span>
|
|
||||||
<span className="font-semibold text-mti-purple-light">
|
|
||||||
{isAssignmentsLoading ? "Loading..." : assignments.filter((a) => !a.archived).length}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
|
||||||
<span className="p-4">Latest students</span>
|
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
|
||||||
{users
|
|
||||||
.filter(studentFilter)
|
|
||||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
|
||||||
.map((x) => (
|
|
||||||
<UserDisplay key={x.id} {...x} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
|
||||||
<span className="p-4">Latest teachers</span>
|
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
|
||||||
{users
|
|
||||||
.filter(teacherFilter)
|
|
||||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
|
||||||
.map((x) => (
|
|
||||||
<UserDisplay key={x.id} {...x} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
|
||||||
<span className="p-4">Highest level students</span>
|
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
|
||||||
{users
|
|
||||||
.filter(studentFilter)
|
|
||||||
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
|
||||||
.map((x) => (
|
|
||||||
<UserDisplay key={x.id} {...x} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
|
||||||
<span className="p-4">Highest exam count students</span>
|
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
|
||||||
{users
|
|
||||||
.filter(studentFilter)
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
|
||||||
)
|
|
||||||
.map((x) => (
|
|
||||||
<UserDisplay key={x.id} {...x} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
|
|
||||||
<>
|
|
||||||
{selectedUser && (
|
|
||||||
<div className="w-full flex flex-col gap-8">
|
|
||||||
<UserCard
|
|
||||||
loggedInUser={user}
|
|
||||||
onClose={(shouldReload) => {
|
|
||||||
setSelectedUser(undefined);
|
|
||||||
if (shouldReload) reload();
|
|
||||||
}}
|
|
||||||
onViewStudents={
|
|
||||||
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
|
||||||
? () => {
|
|
||||||
appendUserFilters({
|
|
||||||
id: "view-students",
|
|
||||||
filter: (x: User) => x.type === "student",
|
|
||||||
});
|
|
||||||
appendUserFilters({
|
|
||||||
id: "belongs-to-admin",
|
|
||||||
filter: (x: User) =>
|
|
||||||
groups
|
|
||||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push("/list/users");
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onViewTeachers={
|
|
||||||
selectedUser.type === "corporate" || selectedUser.type === "student"
|
|
||||||
? () => {
|
|
||||||
appendUserFilters({
|
|
||||||
id: "view-teachers",
|
|
||||||
filter: (x: User) => x.type === "teacher",
|
|
||||||
});
|
|
||||||
appendUserFilters({
|
|
||||||
id: "belongs-to-admin",
|
|
||||||
filter: (x: User) =>
|
|
||||||
groups
|
|
||||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push("/list/users");
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
user={selectedUser}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
</Modal>
|
|
||||||
{page === "students" && <StudentsList />}
|
|
||||||
{page === "teachers" && <TeachersList />}
|
|
||||||
{page === "groups" && <GroupsList />}
|
|
||||||
{page === "assignments" && <AssignmentsPage />}
|
|
||||||
{page === "studentsPerformance" && <StudentPerformancePage />}
|
|
||||||
{page === "" && <DefaultDashboard />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
45
src/dashboards/Corporate/MasterStatisticalPage.tsx
Normal file
45
src/dashboards/Corporate/MasterStatisticalPage.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import useUsers, {userHashStudent, userHashTeacher} from "@/hooks/useUsers";
|
||||||
|
import {CorporateUser, User} from "@/interfaces/user";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import {useMemo} from "react";
|
||||||
|
import {BsArrowLeft} from "react-icons/bs";
|
||||||
|
import MasterStatistical from "../MasterCorporate/MasterStatistical";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: CorporateUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MasterStatisticalPage = ({user}: Props) => {
|
||||||
|
const {users: students} = useUsers(userHashStudent);
|
||||||
|
const {users: teachers} = useUsers(userHashTeacher);
|
||||||
|
|
||||||
|
// this workaround will allow us toreuse the master statistical due to master corporate restraints
|
||||||
|
// while still being able to use the corporate user
|
||||||
|
const groupedByNameCorporateIds = useMemo(
|
||||||
|
() => ({
|
||||||
|
[user.corporateInformation?.companyInformation?.name || user.name]: [user.id],
|
||||||
|
}),
|
||||||
|
[user],
|
||||||
|
);
|
||||||
|
|
||||||
|
const teachersAndStudents = useMemo(() => [...students, ...teachers], [students, teachers]);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Master Statistical</h2>
|
||||||
|
</div>
|
||||||
|
<MasterStatistical users={teachersAndStudents} corporateUsers={groupedByNameCorporateIds} displaySelection={false} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MasterStatisticalPage;
|
||||||
154
src/dashboards/Corporate/StudentPerformanceList.tsx
Normal file
154
src/dashboards/Corporate/StudentPerformanceList.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
|
import useUsers, {userHashStudent, userHashTeacher, userHashCorporate} from "@/hooks/useUsers";
|
||||||
|
import {CorporateUser, Group, MasterCorporateUser, Stat, User} from "@/interfaces/user";
|
||||||
|
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
|
import {dateSorter} from "@/utils";
|
||||||
|
import moment from "moment";
|
||||||
|
import {useEffect, useMemo, useState} from "react";
|
||||||
|
import {
|
||||||
|
BsArrowLeft,
|
||||||
|
BsClipboard2Data,
|
||||||
|
BsClipboard2DataFill,
|
||||||
|
BsClock,
|
||||||
|
BsGlobeCentralSouthAsia,
|
||||||
|
BsPaperclip,
|
||||||
|
BsPerson,
|
||||||
|
BsPersonAdd,
|
||||||
|
BsPersonFill,
|
||||||
|
BsPersonFillGear,
|
||||||
|
BsPersonGear,
|
||||||
|
BsPencilSquare,
|
||||||
|
BsPersonBadge,
|
||||||
|
BsPersonCheck,
|
||||||
|
BsPeople,
|
||||||
|
BsArrowRepeat,
|
||||||
|
BsPlus,
|
||||||
|
BsEnvelopePaper,
|
||||||
|
} from "react-icons/bs";
|
||||||
|
import UserCard from "@/components/UserCard";
|
||||||
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score";
|
||||||
|
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||||
|
import {Module} from "@/interfaces";
|
||||||
|
import {groupByExam} from "@/utils/stats";
|
||||||
|
import IconCard from "../IconCard";
|
||||||
|
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
||||||
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import useCodes from "@/hooks/useCodes";
|
||||||
|
import {getUserCorporate} from "@/utils/groups";
|
||||||
|
import useAssignments from "@/hooks/useAssignments";
|
||||||
|
import {Assignment} from "@/interfaces/results";
|
||||||
|
import AssignmentView from "../AssignmentView";
|
||||||
|
import AssignmentCreator from "../AssignmentCreator";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import AssignmentCard from "../AssignmentCard";
|
||||||
|
import {createColumnHelper} from "@tanstack/react-table";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
import List from "@/components/List";
|
||||||
|
import {getUserCompanyName} from "@/resources/user";
|
||||||
|
import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments";
|
||||||
|
import useUserBalance from "@/hooks/useUserBalance";
|
||||||
|
import AssignmentsPage from "../views/AssignmentsPage";
|
||||||
|
|
||||||
|
type StudentPerformanceItem = User & {corporateName: string; group: string};
|
||||||
|
const StudentPerformanceList = ({items, stats, users}: {items: StudentPerformanceItem[]; stats: Stat[]; users: User[]}) => {
|
||||||
|
const [isShowingAmount, setIsShowingAmount] = useState(false);
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<StudentPerformanceItem>();
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
columnHelper.accessor("name", {
|
||||||
|
header: "Student Name",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("email", {
|
||||||
|
header: "E-mail",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("demographicInformation.passport_id", {
|
||||||
|
header: "ID",
|
||||||
|
cell: (info) => info.getValue() || "N/A",
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("group", {
|
||||||
|
header: "Group",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("corporateName", {
|
||||||
|
header: "Corporate",
|
||||||
|
cell: (info) => info.getValue() || "N/A",
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.reading", {
|
||||||
|
header: "Reading",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? info.getValue() || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.listening", {
|
||||||
|
header: "Listening",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? info.getValue() || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.writing", {
|
||||||
|
header: "Writing",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? info.getValue() || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.speaking", {
|
||||||
|
header: "Speaking",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? info.getValue() || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.level", {
|
||||||
|
header: "Level",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? info.getValue() || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels", {
|
||||||
|
id: "overall_level",
|
||||||
|
header: "Overall",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? averageLevelCalculator(
|
||||||
|
users,
|
||||||
|
stats.filter((x) => x.user === info.row.original.id),
|
||||||
|
).toFixed(1)
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 w-full h-full">
|
||||||
|
<Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}>
|
||||||
|
Show Utilization
|
||||||
|
</Checkbox>
|
||||||
|
<List<StudentPerformanceItem>
|
||||||
|
data={items.sort(
|
||||||
|
(a, b) =>
|
||||||
|
averageLevelCalculator(
|
||||||
|
users,
|
||||||
|
stats.filter((x) => x.user === b.id),
|
||||||
|
) -
|
||||||
|
averageLevelCalculator(
|
||||||
|
users,
|
||||||
|
stats.filter((x) => x.user === a.id),
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
columns={columns}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StudentPerformanceList;
|
||||||
49
src/dashboards/Corporate/StudentPerformancePage.tsx
Normal file
49
src/dashboards/Corporate/StudentPerformancePage.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
import useUsers, {userHashStudent} from "@/hooks/useUsers";
|
||||||
|
import {Stat, User} from "@/interfaces/user";
|
||||||
|
import {getUserCompanyName} from "@/resources/user";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import {BsArrowLeft, BsArrowRepeat} from "react-icons/bs";
|
||||||
|
import StudentPerformanceList from "./StudentPerformanceList";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StudentPerformancePage = ({user}: Props) => {
|
||||||
|
const {groups} = useGroups({admin: user.id});
|
||||||
|
const {users: students, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(userHashStudent);
|
||||||
|
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const performanceStudents = students.map((u) => ({
|
||||||
|
...u,
|
||||||
|
group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A",
|
||||||
|
corporateName: getUserCompanyName(user, [], groups),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="w-full flex justify-between items-center">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={reloadStudents}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<span>Reload</span>
|
||||||
|
<BsArrowRepeat className={clsx("text-xl", isStudentsLoading && "animate-spin")} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StudentPerformanceList items={performanceStudents} stats={stats} users={students} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StudentPerformancePage;
|
||||||
400
src/dashboards/Corporate/index.tsx
Normal file
400
src/dashboards/Corporate/index.tsx
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
|
import useUsers, {userHashStudent, userHashTeacher, userHashCorporate} from "@/hooks/useUsers";
|
||||||
|
import {CorporateUser, Group, MasterCorporateUser, Stat, User} from "@/interfaces/user";
|
||||||
|
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
|
import {dateSorter} from "@/utils";
|
||||||
|
import moment from "moment";
|
||||||
|
import {useEffect, useMemo, useState} from "react";
|
||||||
|
import {
|
||||||
|
BsArrowLeft,
|
||||||
|
BsClipboard2Data,
|
||||||
|
BsClipboard2DataFill,
|
||||||
|
BsClock,
|
||||||
|
BsGlobeCentralSouthAsia,
|
||||||
|
BsPaperclip,
|
||||||
|
BsPerson,
|
||||||
|
BsPersonAdd,
|
||||||
|
BsPersonFill,
|
||||||
|
BsPersonFillGear,
|
||||||
|
BsPersonGear,
|
||||||
|
BsPencilSquare,
|
||||||
|
BsPersonBadge,
|
||||||
|
BsPersonCheck,
|
||||||
|
BsPeople,
|
||||||
|
BsArrowRepeat,
|
||||||
|
BsPlus,
|
||||||
|
BsEnvelopePaper,
|
||||||
|
} from "react-icons/bs";
|
||||||
|
import UserCard from "@/components/UserCard";
|
||||||
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score";
|
||||||
|
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||||
|
import {Module} from "@/interfaces";
|
||||||
|
import {groupByExam} from "@/utils/stats";
|
||||||
|
import IconCard from "../IconCard";
|
||||||
|
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
||||||
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import useCodes from "@/hooks/useCodes";
|
||||||
|
import {getUserCorporate} from "@/utils/groups";
|
||||||
|
import useAssignments from "@/hooks/useAssignments";
|
||||||
|
import {Assignment} from "@/interfaces/results";
|
||||||
|
import AssignmentView from "../AssignmentView";
|
||||||
|
import AssignmentCreator from "../AssignmentCreator";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import AssignmentCard from "../AssignmentCard";
|
||||||
|
import {createColumnHelper} from "@tanstack/react-table";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
import List from "@/components/List";
|
||||||
|
import {getUserCompanyName} from "@/resources/user";
|
||||||
|
import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments";
|
||||||
|
import useUserBalance from "@/hooks/useUserBalance";
|
||||||
|
import AssignmentsPage from "../views/AssignmentsPage";
|
||||||
|
import StudentPerformancePage from "./StudentPerformancePage";
|
||||||
|
import MasterStatistical from "../MasterCorporate/MasterStatistical";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: CorporateUser;
|
||||||
|
linkedCorporate?: CorporateUser | MasterCorporateUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const studentHash = {
|
||||||
|
type: "student",
|
||||||
|
orderBy: "registrationDate",
|
||||||
|
size: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
const teacherHash = {
|
||||||
|
type: "teacher",
|
||||||
|
orderBy: "registrationDate",
|
||||||
|
size: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CorporateDashboard({user, linkedCorporate}: Props) {
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
|
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
||||||
|
const {groups} = useGroups({admin: user.id});
|
||||||
|
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
|
||||||
|
const {balance} = useUserBalance();
|
||||||
|
|
||||||
|
const {users: students, total: totalStudents, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(studentHash);
|
||||||
|
const {users: teachers, total: totalTeachers, reload: reloadTeachers, isLoading: isTeachersLoading} = useUsers(teacherHash);
|
||||||
|
|
||||||
|
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(
|
||||||
|
() =>
|
||||||
|
[...teachers, ...students].filter((x) =>
|
||||||
|
!!selectedUser
|
||||||
|
? groups
|
||||||
|
.filter((g) => g.admin === selectedUser.id)
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(x.id) || false
|
||||||
|
: groups.flatMap((g) => g.participants).includes(x.id),
|
||||||
|
),
|
||||||
|
[groups, teachers, students, selectedUser],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShowModal(!!selectedUser && router.asPath === "/#");
|
||||||
|
}, [selectedUser, router.asPath]);
|
||||||
|
|
||||||
|
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
||||||
|
|
||||||
|
const UserDisplay = (displayUser: User) => (
|
||||||
|
<div
|
||||||
|
onClick={() => setSelectedUser(displayUser)}
|
||||||
|
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
||||||
|
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
||||||
|
<div className="flex flex-col gap-1 items-start">
|
||||||
|
<span>{displayUser.name}</span>
|
||||||
|
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const GroupsList = () => {
|
||||||
|
const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Groups ({groups.filter(filter).length})</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GroupList user={user} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const averageLevelCalculator = (studentStats: Stat[]) => {
|
||||||
|
const formattedStats = studentStats
|
||||||
|
.map((s) => ({
|
||||||
|
focus: students.find((u) => u.id === s.user)?.focus,
|
||||||
|
score: s.score,
|
||||||
|
module: s.module,
|
||||||
|
}))
|
||||||
|
.filter((f) => !!f.focus);
|
||||||
|
const bandScores = formattedStats.map((s) => ({
|
||||||
|
module: s.module,
|
||||||
|
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const levels: {[key in Module]: number} = {
|
||||||
|
reading: 0,
|
||||||
|
listening: 0,
|
||||||
|
writing: 0,
|
||||||
|
speaking: 0,
|
||||||
|
level: 0,
|
||||||
|
};
|
||||||
|
bandScores.forEach((b) => (levels[b.module] += b.level));
|
||||||
|
|
||||||
|
return calculateAverageLevel(levels);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (router.asPath === "/#students")
|
||||||
|
return (
|
||||||
|
<UserList
|
||||||
|
user={user}
|
||||||
|
type="student"
|
||||||
|
renderHeader={(total) => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Students ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (router.asPath === "/#teachers")
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (router.asPath === "/#groups") return <GroupsList />;
|
||||||
|
if (router.asPath === "/#studentsPerformance") return <StudentPerformancePage user={user} />;
|
||||||
|
|
||||||
|
if (router.asPath === "/#assignments")
|
||||||
|
return (
|
||||||
|
<AssignmentsPage
|
||||||
|
assignments={assignments}
|
||||||
|
user={user}
|
||||||
|
groups={assignmentsGroups}
|
||||||
|
reloadAssignments={reloadAssignments}
|
||||||
|
isLoading={isAssignmentsLoading}
|
||||||
|
onBack={() => router.push("/")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
|
||||||
|
<>
|
||||||
|
{selectedUser && (
|
||||||
|
<div className="w-full flex flex-col gap-8">
|
||||||
|
<UserCard
|
||||||
|
loggedInUser={user}
|
||||||
|
onClose={(shouldReload) => {
|
||||||
|
setSelectedUser(undefined);
|
||||||
|
if (shouldReload && selectedUser!.type === "student") reloadStudents();
|
||||||
|
if (shouldReload && selectedUser!.type === "teacher") reloadTeachers();
|
||||||
|
}}
|
||||||
|
onViewStudents={
|
||||||
|
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
||||||
|
? () => {
|
||||||
|
appendUserFilters({
|
||||||
|
id: "view-students",
|
||||||
|
filter: (x: User) => x.type === "student",
|
||||||
|
});
|
||||||
|
appendUserFilters({
|
||||||
|
id: "belongs-to-admin",
|
||||||
|
filter: (x: User) =>
|
||||||
|
groups
|
||||||
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(x.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push("/list/users");
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onViewTeachers={
|
||||||
|
selectedUser.type === "corporate" || selectedUser.type === "student"
|
||||||
|
? () => {
|
||||||
|
appendUserFilters({
|
||||||
|
id: "view-teachers",
|
||||||
|
filter: (x: User) => x.type === "teacher",
|
||||||
|
});
|
||||||
|
appendUserFilters({
|
||||||
|
id: "belongs-to-admin",
|
||||||
|
filter: (x: User) =>
|
||||||
|
groups
|
||||||
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(x.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push("/list/users");
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
user={selectedUser}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<>
|
||||||
|
{!!linkedCorporate && (
|
||||||
|
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
|
||||||
|
Linked to: <b>{linkedCorporate?.corporateInformation?.companyInformation.name || linkedCorporate.name}</b>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<section className="grid grid-cols-5 -md:grid-cols-2 gap-4 text-center">
|
||||||
|
<IconCard
|
||||||
|
onClick={() => router.push("/#students")}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
|
Icon={BsPersonFill}
|
||||||
|
label="Students"
|
||||||
|
value={totalStudents}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
onClick={() => router.push("/#teachers")}
|
||||||
|
isLoading={isTeachersLoading}
|
||||||
|
Icon={BsPencilSquare}
|
||||||
|
label="Teachers"
|
||||||
|
value={totalTeachers}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsClipboard2Data}
|
||||||
|
label="Exams Performed"
|
||||||
|
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsPaperclip}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
|
label="Average Level"
|
||||||
|
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard onClick={() => router.push("/#groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
|
||||||
|
<IconCard
|
||||||
|
Icon={BsPersonCheck}
|
||||||
|
label="User Balance"
|
||||||
|
value={`${balance}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsClock}
|
||||||
|
label="Expiration Date"
|
||||||
|
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
|
||||||
|
color="rose"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsPersonFillGear}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
|
label="Student Performance"
|
||||||
|
value={totalStudents}
|
||||||
|
color="purple"
|
||||||
|
onClick={() => router.push("/#studentsPerformance")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
disabled={isAssignmentsLoading}
|
||||||
|
onClick={() => router.push("/#assignments")}
|
||||||
|
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||||
|
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
||||||
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
|
<span className="text-lg">Assignments</span>
|
||||||
|
<span className="font-semibold text-mti-purple-light">
|
||||||
|
{isAssignmentsLoading ? "Loading..." : assignments.filter((a) => !a.archived).length}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Latest students</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{students
|
||||||
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Latest teachers</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{teachers
|
||||||
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Highest level students</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{students
|
||||||
|
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Highest exam count students</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{students
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
||||||
|
)
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,838 +0,0 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
|
||||||
import Modal from "@/components/Modal";
|
|
||||||
import useStats from "@/hooks/useStats";
|
|
||||||
import useUsers from "@/hooks/useUsers";
|
|
||||||
import {CorporateUser, Group, MasterCorporateUser, Stat, User} from "@/interfaces/user";
|
|
||||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
|
||||||
import {dateSorter} from "@/utils";
|
|
||||||
import moment from "moment";
|
|
||||||
import {useEffect, useState} from "react";
|
|
||||||
import {
|
|
||||||
BsArrowLeft,
|
|
||||||
BsClipboard2Data,
|
|
||||||
BsClock,
|
|
||||||
BsPaperclip,
|
|
||||||
BsPersonFill,
|
|
||||||
BsPencilSquare,
|
|
||||||
BsPersonCheck,
|
|
||||||
BsPeople,
|
|
||||||
BsBank,
|
|
||||||
BsEnvelopePaper,
|
|
||||||
BsArrowRepeat,
|
|
||||||
BsPlus,
|
|
||||||
BsPersonFillGear,
|
|
||||||
BsFilter,
|
|
||||||
BsDatabase,
|
|
||||||
} from "react-icons/bs";
|
|
||||||
import UserCard from "@/components/UserCard";
|
|
||||||
import useGroups from "@/hooks/useGroups";
|
|
||||||
|
|
||||||
import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score";
|
|
||||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
|
||||||
import {Module} from "@/interfaces";
|
|
||||||
import {groupByExam} from "@/utils/stats";
|
|
||||||
import IconCard from "./IconCard";
|
|
||||||
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
|
||||||
import useFilterStore from "@/stores/listFilterStore";
|
|
||||||
import {useRouter} from "next/router";
|
|
||||||
import useCodes from "@/hooks/useCodes";
|
|
||||||
import useAssignments from "@/hooks/useAssignments";
|
|
||||||
import {Assignment} from "@/interfaces/results";
|
|
||||||
import AssignmentView from "./AssignmentView";
|
|
||||||
import AssignmentCreator from "./AssignmentCreator";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import AssignmentCard from "./AssignmentCard";
|
|
||||||
import {createColumn, createColumnHelper} from "@tanstack/react-table";
|
|
||||||
import List from "@/components/List";
|
|
||||||
import {getUserCorporate} from "@/utils/groups";
|
|
||||||
import {getCorporateUser, getUserCompanyName} from "@/resources/user";
|
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
|
||||||
import {groupBy, uniq, uniqBy} from "lodash";
|
|
||||||
import Select from "@/components/Low/Select";
|
|
||||||
import {Menu, MenuButton, MenuItem, MenuItems} from "@headlessui/react";
|
|
||||||
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
|
|
||||||
import MasterStatistical from "./MasterStatistical";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
user: MasterCorporateUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
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());
|
|
||||||
|
|
||||||
type StudentPerformanceItem = User & {corporate?: CorporateUser; group?: Group};
|
|
||||||
const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPerformanceItem[]; stats: Stat[]; users: User[]; groups: Group[]}) => {
|
|
||||||
const [isShowingAmount, setIsShowingAmount] = useState(false);
|
|
||||||
const [availableCorporates] = useState(
|
|
||||||
uniqBy(
|
|
||||||
items.map((x) => x.corporate),
|
|
||||||
"id",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const [availableGroups] = useState(
|
|
||||||
uniqBy(
|
|
||||||
items.map((x) => x.group),
|
|
||||||
"id",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const [selectedCorporate, setSelectedCorporate] = useState<CorporateUser | null | undefined>(null);
|
|
||||||
const [selectedGroup, setSelectedGroup] = useState<Group | null | undefined>(null);
|
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<StudentPerformanceItem>();
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
columnHelper.accessor("name", {
|
|
||||||
header: "Student Name",
|
|
||||||
cell: (info) => info.getValue(),
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("email", {
|
|
||||||
header: "E-mail",
|
|
||||||
cell: (info) => info.getValue(),
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("demographicInformation.passport_id", {
|
|
||||||
header: "ID",
|
|
||||||
cell: (info) => info.getValue() || "N/A",
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("group", {
|
|
||||||
header: "Group",
|
|
||||||
cell: (info) => info.getValue()?.name || "N/A",
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("corporate", {
|
|
||||||
header: "Corporate",
|
|
||||||
cell: (info) => (!!info.getValue() ? getUserCompanyName(info.getValue() as User, users, groups) : "N/A"),
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("levels.reading", {
|
|
||||||
header: "Reading",
|
|
||||||
cell: (info) =>
|
|
||||||
!isShowingAmount
|
|
||||||
? calculateBandScore(
|
|
||||||
stats
|
|
||||||
.filter((x) => x.module === "reading" && x.user === info.row.original.id)
|
|
||||||
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
|
||||||
stats
|
|
||||||
.filter((x) => x.module === "reading" && x.user === info.row.original.id)
|
|
||||||
.reduce((acc, curr) => acc + curr.score.total, 0),
|
|
||||||
"level",
|
|
||||||
info.row.original.focus || "academic",
|
|
||||||
) || 0
|
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("levels.listening", {
|
|
||||||
header: "Listening",
|
|
||||||
cell: (info) =>
|
|
||||||
!isShowingAmount
|
|
||||||
? calculateBandScore(
|
|
||||||
stats
|
|
||||||
.filter((x) => x.module === "listening" && x.user === info.row.original.id)
|
|
||||||
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
|
||||||
stats
|
|
||||||
.filter((x) => x.module === "listening" && x.user === info.row.original.id)
|
|
||||||
.reduce((acc, curr) => acc + curr.score.total, 0),
|
|
||||||
"level",
|
|
||||||
info.row.original.focus || "academic",
|
|
||||||
) || 0
|
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("levels.writing", {
|
|
||||||
header: "Writing",
|
|
||||||
cell: (info) =>
|
|
||||||
!isShowingAmount
|
|
||||||
? calculateBandScore(
|
|
||||||
stats
|
|
||||||
.filter((x) => x.module === "writing" && x.user === info.row.original.id)
|
|
||||||
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
|
||||||
stats
|
|
||||||
.filter((x) => x.module === "writing" && x.user === info.row.original.id)
|
|
||||||
.reduce((acc, curr) => acc + curr.score.total, 0),
|
|
||||||
"level",
|
|
||||||
info.row.original.focus || "academic",
|
|
||||||
) || 0
|
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("levels.speaking", {
|
|
||||||
header: "Speaking",
|
|
||||||
cell: (info) =>
|
|
||||||
!isShowingAmount
|
|
||||||
? calculateBandScore(
|
|
||||||
stats
|
|
||||||
.filter((x) => x.module === "speaking" && x.user === info.row.original.id)
|
|
||||||
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
|
||||||
stats
|
|
||||||
.filter((x) => x.module === "speaking" && x.user === info.row.original.id)
|
|
||||||
.reduce((acc, curr) => acc + curr.score.total, 0),
|
|
||||||
"level",
|
|
||||||
info.row.original.focus || "academic",
|
|
||||||
) || 0
|
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("levels.level", {
|
|
||||||
header: "Level",
|
|
||||||
cell: (info) =>
|
|
||||||
!isShowingAmount
|
|
||||||
? calculateBandScore(
|
|
||||||
stats
|
|
||||||
.filter((x) => x.module === "level" && x.user === info.row.original.id)
|
|
||||||
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
|
||||||
stats
|
|
||||||
.filter((x) => x.module === "level" && x.user === info.row.original.id)
|
|
||||||
.reduce((acc, curr) => acc + curr.score.total, 0),
|
|
||||||
"level",
|
|
||||||
info.row.original.focus || "academic",
|
|
||||||
) || 0
|
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("levels", {
|
|
||||||
id: "overall_level",
|
|
||||||
header: "Overall",
|
|
||||||
cell: (info) =>
|
|
||||||
!isShowingAmount
|
|
||||||
? averageLevelCalculator(
|
|
||||||
users,
|
|
||||||
stats.filter((x) => x.user === info.row.original.id),
|
|
||||||
).toFixed(1)
|
|
||||||
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
const filterUsers = (data: StudentPerformanceItem[]) => {
|
|
||||||
console.log(data, selectedCorporate);
|
|
||||||
const filterByCorporate = (item: StudentPerformanceItem) => item.corporate?.id === selectedCorporate?.id;
|
|
||||||
const filterByGroup = (item: StudentPerformanceItem) => item.group?.id === selectedGroup?.id;
|
|
||||||
|
|
||||||
const filters: ((item: StudentPerformanceItem) => boolean)[] = [];
|
|
||||||
if (selectedCorporate !== null) filters.push(filterByCorporate);
|
|
||||||
if (selectedGroup !== null) filters.push(filterByGroup);
|
|
||||||
|
|
||||||
return filters.reduce((d, f) => d.filter(f), data);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4 w-full h-full">
|
|
||||||
<div className="w-full flex gap-4 justify-between items-center">
|
|
||||||
<Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}>
|
|
||||||
Show Utilization
|
|
||||||
</Checkbox>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger>
|
|
||||||
<div className="flex items-center justify-center p-2 hover:bg-neutral-300/50 rounded-full transition ease-in-out duration-300">
|
|
||||||
<BsFilter size={20} />
|
|
||||||
</div>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-96">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<span className="font-bold text-lg">Filters</span>
|
|
||||||
<Select
|
|
||||||
options={availableCorporates.map((x) => ({
|
|
||||||
value: x?.id || "N/A",
|
|
||||||
label: x?.corporateInformation?.companyInformation?.name || x?.name || "N/A",
|
|
||||||
}))}
|
|
||||||
isClearable
|
|
||||||
value={
|
|
||||||
selectedCorporate === null
|
|
||||||
? null
|
|
||||||
: {
|
|
||||||
value: selectedCorporate?.id || "N/A",
|
|
||||||
label:
|
|
||||||
selectedCorporate?.corporateInformation?.companyInformation?.name ||
|
|
||||||
selectedCorporate?.name ||
|
|
||||||
"N/A",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
placeholder="Select a Corporate..."
|
|
||||||
onChange={(value) =>
|
|
||||||
!value
|
|
||||||
? setSelectedCorporate(null)
|
|
||||||
: setSelectedCorporate(
|
|
||||||
value.value === "N/A" ? undefined : availableCorporates.find((x) => x?.id === value.value),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
options={availableGroups.map((x) => ({
|
|
||||||
value: x?.id || "N/A",
|
|
||||||
label: x?.name || "N/A",
|
|
||||||
}))}
|
|
||||||
isClearable
|
|
||||||
value={
|
|
||||||
selectedGroup === null
|
|
||||||
? null
|
|
||||||
: {
|
|
||||||
value: selectedGroup?.id || "N/A",
|
|
||||||
label: selectedGroup?.name || "N/A",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
placeholder="Select a Group..."
|
|
||||||
onChange={(value) =>
|
|
||||||
!value
|
|
||||||
? setSelectedGroup(null)
|
|
||||||
: setSelectedGroup(value.value === "N/A" ? undefined : availableGroups.find((x) => x?.id === value.value))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
<List<StudentPerformanceItem>
|
|
||||||
data={filterUsers(
|
|
||||||
items.sort(
|
|
||||||
(a, b) =>
|
|
||||||
averageLevelCalculator(
|
|
||||||
users,
|
|
||||||
stats.filter((x) => x.user === b.id),
|
|
||||||
) -
|
|
||||||
averageLevelCalculator(
|
|
||||||
users,
|
|
||||||
stats.filter((x) => x.user === a.id),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
columns={columns}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function MasterCorporateDashboard({user}: Props) {
|
|
||||||
const [page, setPage] = useState("");
|
|
||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
|
|
||||||
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
|
||||||
const [corporateAssignments, setCorporateAssignments] = useState<(Assignment & {corporate?: CorporateUser})[]>([]);
|
|
||||||
|
|
||||||
const {stats} = useStats();
|
|
||||||
const {users, reload} = useUsers();
|
|
||||||
const {codes} = useCodes(user.id);
|
|
||||||
const {groups} = useGroups({admin: user.id, userType: user.type});
|
|
||||||
|
|
||||||
const masterCorporateUserGroups = [...new Set(groups.filter((u) => u.admin === user.id).flatMap((g) => g.participants))];
|
|
||||||
|
|
||||||
const corporateUserGroups = [...new Set(groups.flatMap((g) => g.participants))];
|
|
||||||
|
|
||||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
|
|
||||||
|
|
||||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setShowModal(!!selectedUser && page === "");
|
|
||||||
}, [selectedUser, page]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCorporateAssignments(
|
|
||||||
assignments.filter(activeFilter).map((a) => ({
|
|
||||||
...a,
|
|
||||||
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);
|
|
||||||
const teacherFilter = (user: User) => user.type === "teacher" && corporateUserGroups.includes(user.id);
|
|
||||||
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
|
||||||
|
|
||||||
const UserDisplay = (displayUser: User) => (
|
|
||||||
<div
|
|
||||||
onClick={() => setSelectedUser(displayUser)}
|
|
||||||
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
|
||||||
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
|
||||||
<div className="flex flex-col gap-1 items-start">
|
|
||||||
<span>{displayUser.name}</span>
|
|
||||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const StudentsList = () => {
|
|
||||||
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 = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div
|
|
||||||
onClick={() => setPage("")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold">Groups ({groups.length})</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<GroupList user={user} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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 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 (
|
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
<StudentPerformanceList items={students} stats={stats} users={users} 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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MasterStatisticalPage = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div
|
|
||||||
onClick={() => setPage("")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold">Master Statistical</h2>
|
|
||||||
</div>
|
|
||||||
<MasterStatistical
|
|
||||||
users={masterCorporateUserGroups.reduce((accm: CorporateUser[], id) => {
|
|
||||||
const user = users.find((u) => u.id === id) as CorporateUser;
|
|
||||||
if (user) return [...accm, user];
|
|
||||||
return accm;
|
|
||||||
}, [])}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const DefaultDashboard = () => (
|
|
||||||
<>
|
|
||||||
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center">
|
|
||||||
<IconCard
|
|
||||||
onClick={() => setPage("students")}
|
|
||||||
Icon={BsPersonFill}
|
|
||||||
label="Students"
|
|
||||||
value={users.filter(studentFilter).length}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
onClick={() => setPage("teachers")}
|
|
||||||
Icon={BsPencilSquare}
|
|
||||||
label="Teachers"
|
|
||||||
value={users.filter(teacherFilter).length}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsClipboard2Data}
|
|
||||||
label="Exams Performed"
|
|
||||||
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsPaperclip}
|
|
||||||
label="Average Level"
|
|
||||||
value={averageLevelCalculator(
|
|
||||||
users,
|
|
||||||
stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)),
|
|
||||||
).toFixed(1)}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard onClick={() => setPage("groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
|
|
||||||
<IconCard
|
|
||||||
Icon={BsPersonCheck}
|
|
||||||
label="User Balance"
|
|
||||||
value={`${codes.length}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsClock}
|
|
||||||
label="Expiration Date"
|
|
||||||
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
|
|
||||||
color="rose"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsBank}
|
|
||||||
label="Corporate"
|
|
||||||
value={masterCorporateUserGroups.length}
|
|
||||||
color="purple"
|
|
||||||
onClick={() => setPage("corporate")}
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsPersonFillGear}
|
|
||||||
label="Student Performance"
|
|
||||||
value={users.filter(studentFilter).length}
|
|
||||||
color="purple"
|
|
||||||
onClick={() => setPage("studentsPerformance")}
|
|
||||||
/>
|
|
||||||
{/* <IconCard
|
|
||||||
Icon={BsDatabase}
|
|
||||||
label="Master Statistical"
|
|
||||||
// value={masterCorporateUserGroups.length}
|
|
||||||
color="purple"
|
|
||||||
onClick={() => setPage("statistical")}
|
|
||||||
/> */}
|
|
||||||
<button
|
|
||||||
disabled={isAssignmentsLoading}
|
|
||||||
onClick={() => setPage("assignments")}
|
|
||||||
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
|
||||||
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
|
||||||
<span className="flex flex-col gap-1 items-center text-xl">
|
|
||||||
<span className="text-lg">Assignments</span>
|
|
||||||
<span className="font-semibold text-mti-purple-light">
|
|
||||||
{isAssignmentsLoading ? "Loading..." : assignments.filter((a) => !a.archived).length}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
|
||||||
<span className="p-4">Latest students</span>
|
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
|
||||||
{users
|
|
||||||
.filter(studentFilter)
|
|
||||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
|
||||||
.map((x) => (
|
|
||||||
<UserDisplay key={x.id} {...x} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
|
||||||
<span className="p-4">Latest teachers</span>
|
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
|
||||||
{users
|
|
||||||
.filter(teacherFilter)
|
|
||||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
|
||||||
.map((x) => (
|
|
||||||
<UserDisplay key={x.id} {...x} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
|
||||||
<span className="p-4">Highest level students</span>
|
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
|
||||||
{users
|
|
||||||
.filter(studentFilter)
|
|
||||||
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
|
||||||
.map((x) => (
|
|
||||||
<UserDisplay key={x.id} {...x} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
|
||||||
<span className="p-4">Highest exam count students</span>
|
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
|
||||||
{users
|
|
||||||
.filter(studentFilter)
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
|
||||||
)
|
|
||||||
.map((x) => (
|
|
||||||
<UserDisplay key={x.id} {...x} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
|
|
||||||
<>
|
|
||||||
{selectedUser && (
|
|
||||||
<div className="w-full flex flex-col gap-8">
|
|
||||||
<UserCard
|
|
||||||
loggedInUser={user}
|
|
||||||
onClose={(shouldReload) => {
|
|
||||||
setSelectedUser(undefined);
|
|
||||||
if (shouldReload) reload();
|
|
||||||
}}
|
|
||||||
onViewStudents={
|
|
||||||
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
|
||||||
? () => {
|
|
||||||
appendUserFilters({
|
|
||||||
id: "view-students",
|
|
||||||
filter: (x: User) => x.type === "student",
|
|
||||||
});
|
|
||||||
appendUserFilters({
|
|
||||||
id: "belongs-to-admin",
|
|
||||||
filter: (x: User) =>
|
|
||||||
groups
|
|
||||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push("/list/users");
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onViewTeachers={
|
|
||||||
selectedUser.type === "corporate" || selectedUser.type === "student"
|
|
||||||
? () => {
|
|
||||||
appendUserFilters({
|
|
||||||
id: "view-teachers",
|
|
||||||
filter: (x: User) => x.type === "teacher",
|
|
||||||
});
|
|
||||||
appendUserFilters({
|
|
||||||
id: "belongs-to-admin",
|
|
||||||
filter: (x: User) =>
|
|
||||||
groups
|
|
||||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push("/list/users");
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
user={selectedUser}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
</Modal>
|
|
||||||
{page === "students" && <StudentsList />}
|
|
||||||
{page === "teachers" && <TeachersList />}
|
|
||||||
{page === "groups" && <GroupsList />}
|
|
||||||
{page === "corporate" && <CorporateList />}
|
|
||||||
{page === "assignments" && <AssignmentsPage />}
|
|
||||||
{page === "studentsPerformance" && <StudentPerformancePage />}
|
|
||||||
{page === "statistical" && <MasterStatisticalPage />}
|
|
||||||
{page === "" && <DefaultDashboard />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
385
src/dashboards/MasterCorporate/MasterStatistical.tsx
Normal file
385
src/dashboards/MasterCorporate/MasterStatistical.tsx
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {CorporateUser, User} from "@/interfaces/user";
|
||||||
|
import {BsFileExcel, BsBank, BsPersonFill} from "react-icons/bs";
|
||||||
|
import IconCard from "../IconCard";
|
||||||
|
|
||||||
|
import useAssignmentsCorporates from "@/hooks/useAssignmentCorporates";
|
||||||
|
import ReactDatePicker from "react-datepicker";
|
||||||
|
|
||||||
|
import moment from "moment";
|
||||||
|
import {AssignmentWithCorporateId} from "@/interfaces/results";
|
||||||
|
import {flexRender, createColumnHelper, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
import {useListSearch} from "@/hooks/useListSearch";
|
||||||
|
import axios from "axios";
|
||||||
|
import {toast} from "react-toastify";
|
||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
|
||||||
|
interface GroupedCorporateUsers {
|
||||||
|
// list of user Ids
|
||||||
|
[key: string]: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
corporateUsers: GroupedCorporateUsers;
|
||||||
|
users: User[];
|
||||||
|
displaySelection?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableData {
|
||||||
|
user: string;
|
||||||
|
email: string;
|
||||||
|
correct: number;
|
||||||
|
corporate: string;
|
||||||
|
submitted: boolean;
|
||||||
|
date: moment.Moment;
|
||||||
|
assignment: string;
|
||||||
|
corporateId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserCount {
|
||||||
|
userCount: number;
|
||||||
|
maxUserCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchFilters = [["email"], ["user"], ["userId"]];
|
||||||
|
|
||||||
|
const MasterStatistical = (props: Props) => {
|
||||||
|
const {users, corporateUsers, displaySelection = true} = props;
|
||||||
|
|
||||||
|
// const corporateRelevantUsers = React.useMemo(
|
||||||
|
// () => corporateUsers.filter((x) => x.type !== "student") as CorporateUser[],
|
||||||
|
// [corporateUsers]
|
||||||
|
// );
|
||||||
|
|
||||||
|
const corporates = React.useMemo(() => Object.values(corporateUsers).flat(), [corporateUsers]);
|
||||||
|
|
||||||
|
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 getCorporatesScoresHash = (data: string[]) =>
|
||||||
|
data.reduce(
|
||||||
|
(accm, id) => ({
|
||||||
|
...accm,
|
||||||
|
[id]: getCorporateScores(id),
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
) as Record<string, UserCount>;
|
||||||
|
|
||||||
|
const getConsolidateScore = (data: Record<string, UserCount>) =>
|
||||||
|
Object.values(data).reduce(
|
||||||
|
(acc: UserCount, {userCount, maxUserCount}: UserCount) => ({
|
||||||
|
userCount: acc.userCount + userCount,
|
||||||
|
maxUserCount: acc.maxUserCount + maxUserCount,
|
||||||
|
}),
|
||||||
|
{userCount: 0, maxUserCount: 0},
|
||||||
|
);
|
||||||
|
|
||||||
|
const corporateScores = getCorporatesScoresHash(corporates);
|
||||||
|
const consolidateScore = getConsolidateScore(corporateScores);
|
||||||
|
|
||||||
|
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>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(displaySelection
|
||||||
|
? [
|
||||||
|
columnHelper.accessor("corporate", {
|
||||||
|
header: "Corporate",
|
||||||
|
id: "corporate",
|
||||||
|
cell: (info) => {
|
||||||
|
return <span>{info.getValue()}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
columnHelper.accessor("corporate", {
|
||||||
|
header: "Corporate",
|
||||||
|
id: "corporate",
|
||||||
|
cell: (info) => {
|
||||||
|
return <span>{info.getValue()}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("assignment", {
|
||||||
|
header: "Assignment",
|
||||||
|
id: "assignment",
|
||||||
|
cell: (info) => {
|
||||||
|
return <span>{info.getValue()}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("submitted", {
|
||||||
|
header: "Submitted",
|
||||||
|
id: "submitted",
|
||||||
|
cell: (info) => {
|
||||||
|
return (
|
||||||
|
<Checkbox isChecked={info.getValue()} disabled onChange={() => {}}>
|
||||||
|
<span></span>
|
||||||
|
</Checkbox>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("correct", {
|
||||||
|
header: "Correct",
|
||||||
|
id: "correct",
|
||||||
|
cell: (info) => {
|
||||||
|
return <span>{info.getValue()}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("date", {
|
||||||
|
header: "Date",
|
||||||
|
id: "date",
|
||||||
|
cell: (info) => {
|
||||||
|
const date = info.getValue();
|
||||||
|
if (date) {
|
||||||
|
return <span>{date.format("DD/MM/YYYY")}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span>{""}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const {rows: filteredRows, renderSearch, text: searchText} = useListSearch(searchFilters, tableResults);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: filteredRows,
|
||||||
|
columns: defaultColumns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const areAllSelected = selectedCorporates.length === corporates.length;
|
||||||
|
|
||||||
|
const getStudentsConsolidateScore = () => {
|
||||||
|
if (tableResults.length === 0) {
|
||||||
|
return {highest: null, lowest: null};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the student with the highest and lowest score
|
||||||
|
return tableResults.reduce(
|
||||||
|
(acc, curr) => {
|
||||||
|
if (curr.correct > acc.highest.correct) {
|
||||||
|
acc.highest = curr;
|
||||||
|
}
|
||||||
|
if (curr.correct < acc.lowest.correct) {
|
||||||
|
acc.lowest = curr;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{highest: tableResults[0], lowest: tableResults[0]},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerDownload = async () => {
|
||||||
|
try {
|
||||||
|
setDownloading(true);
|
||||||
|
const res = await axios.post("/api/assignments/statistical/excel", {
|
||||||
|
ids: selectedCorporates,
|
||||||
|
...(startDate ? {startDate: startDate.toISOString()} : {}),
|
||||||
|
...(endDate ? {endDate: endDate.toISOString()} : {}),
|
||||||
|
searchText,
|
||||||
|
});
|
||||||
|
toast.success("Report ready!");
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = res.data;
|
||||||
|
// download should have worked but there are some CORS issues
|
||||||
|
// https://firebase.google.com/docs/storage/web/download-files#cors_configuration
|
||||||
|
// link.download="report.pdf";
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noreferrer";
|
||||||
|
link.click();
|
||||||
|
setDownloading(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Failed to display the report!");
|
||||||
|
console.error(err);
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const consolidateResults = getStudentsConsolidateScore();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{displaySelection && (
|
||||||
|
<div className="flex flex-wrap gap-2 items-center text-center">
|
||||||
|
<IconCard
|
||||||
|
Icon={BsBank}
|
||||||
|
label="Consolidate"
|
||||||
|
value={getConsolidateScoreStr(consolidateScore)}
|
||||||
|
color="purple"
|
||||||
|
onClick={() => {
|
||||||
|
if (areAllSelected) {
|
||||||
|
setSelectedCorporates([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedCorporates(corporates);
|
||||||
|
}}
|
||||||
|
isSelected={areAllSelected}
|
||||||
|
/>
|
||||||
|
{Object.keys(corporateUsers).map((corporateName) => {
|
||||||
|
const group = corporateUsers[corporateName];
|
||||||
|
const isSelected = group.every((id) => selectedCorporates.includes(id));
|
||||||
|
|
||||||
|
const valueHash = getCorporatesScoresHash(group);
|
||||||
|
const value = getConsolidateScoreStr(getConsolidateScore(valueHash));
|
||||||
|
return (
|
||||||
|
<IconCard
|
||||||
|
key={corporateName}
|
||||||
|
Icon={BsBank}
|
||||||
|
label={corporateName}
|
||||||
|
value={value}
|
||||||
|
color="purple"
|
||||||
|
onClick={() => {
|
||||||
|
if (isSelected) {
|
||||||
|
setSelectedCorporates((prev) => prev.filter((x) => !group.includes(x)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedCorporates((prev) => [...new Set([...prev, ...group])]);
|
||||||
|
}}
|
||||||
|
isSelected={isSelected}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3 w-full">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Date</label>
|
||||||
|
<ReactDatePicker
|
||||||
|
dateFormat="dd/MM/yyyy"
|
||||||
|
className="px-4 py-6 w-52 text-sm text-center font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
|
selected={startDate}
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
selectsRange
|
||||||
|
showMonthDropdown
|
||||||
|
onChange={([initialDate, finalDate]: [Date, Date]) => {
|
||||||
|
setStartDate(initialDate ?? moment("01/01/2023").toDate());
|
||||||
|
if (finalDate) {
|
||||||
|
// basicly selecting a final day works as if I'm selecting the first
|
||||||
|
// minute of that day. this way it covers the whole day
|
||||||
|
setEndDate(moment(finalDate).endOf("day").toDate());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEndDate(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{renderSearch()}
|
||||||
|
<div className="flex flex-col gap-3 justify-end">
|
||||||
|
<Button className="max-w-[200px] h-[70px]" variant="outline" onClick={triggerDownload}>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<table className="rounded-xl h-full bg-mti-purple-ultralight/40 w-full">
|
||||||
|
<thead>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<th className="p-4 text-left" key={header.id}>
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody className="px-2">
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td className="px-4 py-2" key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 items-center text-center">
|
||||||
|
{consolidateResults.highest && (
|
||||||
|
<IconCard onClick={() => {}} Icon={BsPersonFill} label={`Highest result: ${consolidateResults.highest.user}`} color="purple" />
|
||||||
|
)}
|
||||||
|
{consolidateResults.lowest && (
|
||||||
|
<IconCard onClick={() => {}} Icon={BsPersonFill} label={`Lowest result: ${consolidateResults.lowest.user}`} color="purple" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MasterStatistical;
|
||||||
52
src/dashboards/MasterCorporate/MasterStatisticalPage.tsx
Normal file
52
src/dashboards/MasterCorporate/MasterStatisticalPage.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import {CorporateUser, User} from "@/interfaces/user";
|
||||||
|
import {groupBy} from "lodash";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import {useMemo} from "react";
|
||||||
|
import {BsArrowLeft} from "react-icons/bs";
|
||||||
|
import MasterStatistical from "./MasterStatistical";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MasterStatisticalPage = () => {
|
||||||
|
const {users} = useUsers();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const groupedByNameCorporates = useMemo(
|
||||||
|
() =>
|
||||||
|
groupBy(
|
||||||
|
users.filter((x) => x.type === "corporate"),
|
||||||
|
(x: CorporateUser) => x.corporateInformation?.companyInformation?.name || "N/A",
|
||||||
|
),
|
||||||
|
[users],
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupedByNameCorporateIds = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.keys(groupedByNameCorporates).reduce((accm, x) => {
|
||||||
|
const corporateUserIds = (groupedByNameCorporates[x] as CorporateUser[]).map((y) => y.id);
|
||||||
|
return {...accm, [x]: corporateUserIds};
|
||||||
|
}, {}),
|
||||||
|
[groupedByNameCorporates],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Master Statistical</h2>
|
||||||
|
</div>
|
||||||
|
<MasterStatistical users={users} corporateUsers={groupedByNameCorporateIds} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MasterStatisticalPage;
|
||||||
252
src/dashboards/MasterCorporate/StudentPerformanceList.tsx
Normal file
252
src/dashboards/MasterCorporate/StudentPerformanceList.tsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import {CorporateUser, Group, Stat, User} from "@/interfaces/user";
|
||||||
|
import {useState} from "react";
|
||||||
|
import {BsFilter} from "react-icons/bs";
|
||||||
|
|
||||||
|
import {averageLevelCalculator, calculateBandScore} from "@/utils/score";
|
||||||
|
import {groupByExam} from "@/utils/stats";
|
||||||
|
import {createColumnHelper} from "@tanstack/react-table";
|
||||||
|
import List from "@/components/List";
|
||||||
|
import {getUserCompanyName} from "@/resources/user";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
import {uniqBy} from "lodash";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
|
||||||
|
|
||||||
|
type StudentPerformanceItem = User & {
|
||||||
|
corporate?: CorporateUser;
|
||||||
|
group?: Group;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPerformanceItem[]; stats: Stat[]; users: User[]; groups: Group[]}) => {
|
||||||
|
const [isShowingAmount, setIsShowingAmount] = useState(false);
|
||||||
|
const [availableCorporates] = useState(
|
||||||
|
uniqBy(
|
||||||
|
items.map((x) => x.corporate),
|
||||||
|
"id",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const [availableGroups] = useState(
|
||||||
|
uniqBy(
|
||||||
|
items.map((x) => x.group),
|
||||||
|
"id",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [selectedCorporate, setSelectedCorporate] = useState<CorporateUser | null | undefined>(null);
|
||||||
|
const [selectedGroup, setSelectedGroup] = useState<Group | null | undefined>(null);
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<StudentPerformanceItem>();
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
columnHelper.accessor("name", {
|
||||||
|
header: "Student Name",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("email", {
|
||||||
|
header: "E-mail",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("demographicInformation.passport_id", {
|
||||||
|
header: "ID",
|
||||||
|
cell: (info) => info.getValue() || "N/A",
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("group", {
|
||||||
|
header: "Group",
|
||||||
|
cell: (info) => info.getValue()?.name || "N/A",
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("corporate", {
|
||||||
|
header: "Corporate",
|
||||||
|
cell: (info) => (!!info.getValue() ? getUserCompanyName(info.getValue() as User, users, groups) : "N/A"),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.reading", {
|
||||||
|
header: "Reading",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? calculateBandScore(
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "reading" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "reading" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.total, 0),
|
||||||
|
"level",
|
||||||
|
info.row.original.focus || "academic",
|
||||||
|
) || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.listening", {
|
||||||
|
header: "Listening",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? calculateBandScore(
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "listening" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "listening" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.total, 0),
|
||||||
|
"level",
|
||||||
|
info.row.original.focus || "academic",
|
||||||
|
) || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.writing", {
|
||||||
|
header: "Writing",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? calculateBandScore(
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "writing" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "writing" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.total, 0),
|
||||||
|
"level",
|
||||||
|
info.row.original.focus || "academic",
|
||||||
|
) || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.speaking", {
|
||||||
|
header: "Speaking",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? calculateBandScore(
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "speaking" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "speaking" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.total, 0),
|
||||||
|
"level",
|
||||||
|
info.row.original.focus || "academic",
|
||||||
|
) || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels.level", {
|
||||||
|
header: "Level",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? calculateBandScore(
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "level" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.correct, 0),
|
||||||
|
stats
|
||||||
|
.filter((x) => x.module === "level" && x.user === info.row.original.id)
|
||||||
|
.reduce((acc, curr) => acc + curr.score.total, 0),
|
||||||
|
"level",
|
||||||
|
info.row.original.focus || "academic",
|
||||||
|
) || 0
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("levels", {
|
||||||
|
id: "overall_level",
|
||||||
|
header: "Overall",
|
||||||
|
cell: (info) =>
|
||||||
|
!isShowingAmount
|
||||||
|
? averageLevelCalculator(
|
||||||
|
users,
|
||||||
|
stats.filter((x) => x.user === info.row.original.id),
|
||||||
|
).toFixed(1)
|
||||||
|
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const filterUsers = (data: StudentPerformanceItem[]) => {
|
||||||
|
const filterByCorporate = (item: StudentPerformanceItem) => item.corporate?.id === selectedCorporate?.id;
|
||||||
|
const filterByGroup = (item: StudentPerformanceItem) => item.group?.id === selectedGroup?.id;
|
||||||
|
|
||||||
|
const filters: ((item: StudentPerformanceItem) => boolean)[] = [];
|
||||||
|
if (selectedCorporate !== null) filters.push(filterByCorporate);
|
||||||
|
if (selectedGroup !== null) filters.push(filterByGroup);
|
||||||
|
|
||||||
|
return filters.reduce((d, f) => d.filter(f), data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 w-full h-full">
|
||||||
|
<div className="w-full flex gap-4 justify-between items-center">
|
||||||
|
<Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}>
|
||||||
|
Show Utilization
|
||||||
|
</Checkbox>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<div className="flex items-center justify-center p-2 hover:bg-neutral-300/50 rounded-full transition ease-in-out duration-300">
|
||||||
|
<BsFilter size={20} />
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-96">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<span className="font-bold text-lg">Filters</span>
|
||||||
|
<Select
|
||||||
|
options={availableCorporates.map((x) => ({
|
||||||
|
value: x?.id || "N/A",
|
||||||
|
label: x?.corporateInformation?.companyInformation?.name || x?.name || "N/A",
|
||||||
|
}))}
|
||||||
|
isClearable
|
||||||
|
value={
|
||||||
|
selectedCorporate === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
value: selectedCorporate?.id || "N/A",
|
||||||
|
label:
|
||||||
|
selectedCorporate?.corporateInformation?.companyInformation?.name ||
|
||||||
|
selectedCorporate?.name ||
|
||||||
|
"N/A",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
placeholder="Select a Corporate..."
|
||||||
|
onChange={(value) =>
|
||||||
|
!value
|
||||||
|
? setSelectedCorporate(null)
|
||||||
|
: setSelectedCorporate(
|
||||||
|
value.value === "N/A" ? undefined : availableCorporates.find((x) => x?.id === value.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
options={availableGroups.map((x) => ({
|
||||||
|
value: x?.id || "N/A",
|
||||||
|
label: x?.name || "N/A",
|
||||||
|
}))}
|
||||||
|
isClearable
|
||||||
|
value={
|
||||||
|
selectedGroup === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
value: selectedGroup?.id || "N/A",
|
||||||
|
label: selectedGroup?.name || "N/A",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
placeholder="Select a Group..."
|
||||||
|
onChange={(value) =>
|
||||||
|
!value
|
||||||
|
? setSelectedGroup(null)
|
||||||
|
: setSelectedGroup(value.value === "N/A" ? undefined : availableGroups.find((x) => x?.id === value.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
<List<StudentPerformanceItem>
|
||||||
|
data={filterUsers(
|
||||||
|
items.sort(
|
||||||
|
(a, b) =>
|
||||||
|
averageLevelCalculator(
|
||||||
|
users,
|
||||||
|
stats.filter((x) => x.user === b.id),
|
||||||
|
) -
|
||||||
|
averageLevelCalculator(
|
||||||
|
users,
|
||||||
|
stats.filter((x) => x.user === a.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
columns={columns}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StudentPerformanceList;
|
||||||
46
src/dashboards/MasterCorporate/StudentPerformancePage.tsx
Normal file
46
src/dashboards/MasterCorporate/StudentPerformancePage.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import useAssignments from "@/hooks/useAssignments";
|
||||||
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
import useUsers, {userHashCorporate, userHashStudent} from "@/hooks/useUsers";
|
||||||
|
import {Stat, User} from "@/interfaces/user";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import {BsArrowLeft, BsArrowRepeat} from "react-icons/bs";
|
||||||
|
import StudentPerformanceList from "./StudentPerformanceList";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StudentPerformancePage = ({user}: Props) => {
|
||||||
|
const {users: students} = useUsers(userHashStudent);
|
||||||
|
const {users: corporates} = useUsers(userHashCorporate);
|
||||||
|
const {groups} = useGroups({admin: user.id, userType: user.type});
|
||||||
|
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
||||||
|
|
||||||
|
const {reload: reloadAssignments, isLoading: isAssignmentsLoading} = useAssignments({corporate: user.id});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="w-full flex justify-between items-center">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={reloadAssignments}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<span>Reload</span>
|
||||||
|
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StudentPerformanceList items={students} stats={stats} users={corporates} groups={groups} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StudentPerformancePage;
|
||||||
441
src/dashboards/MasterCorporate/index.tsx
Normal file
441
src/dashboards/MasterCorporate/index.tsx
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import {CorporateUser, MasterCorporateUser, Stat, User} from "@/interfaces/user";
|
||||||
|
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
|
import {dateSorter} from "@/utils";
|
||||||
|
import moment from "moment";
|
||||||
|
import {useEffect, useState, useMemo} from "react";
|
||||||
|
import {
|
||||||
|
BsArrowLeft,
|
||||||
|
BsClipboard2Data,
|
||||||
|
BsClock,
|
||||||
|
BsPaperclip,
|
||||||
|
BsPersonFill,
|
||||||
|
BsPencilSquare,
|
||||||
|
BsPersonCheck,
|
||||||
|
BsPeople,
|
||||||
|
BsBank,
|
||||||
|
BsEnvelopePaper,
|
||||||
|
BsArrowRepeat,
|
||||||
|
BsPersonFillGear,
|
||||||
|
BsDatabase,
|
||||||
|
} from "react-icons/bs";
|
||||||
|
import UserCard from "@/components/UserCard";
|
||||||
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
|
||||||
|
import {averageLevelCalculator, calculateAverageLevel} from "@/utils/score";
|
||||||
|
import {groupByExam} from "@/utils/stats";
|
||||||
|
import IconCard from "../IconCard";
|
||||||
|
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
||||||
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import useAssignments from "@/hooks/useAssignments";
|
||||||
|
import {Assignment} from "@/interfaces/results";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {getCorporateUser} from "@/resources/user";
|
||||||
|
import {groupBy, uniqBy} from "lodash";
|
||||||
|
import MasterStatistical from "./MasterStatistical";
|
||||||
|
import {activeAssignmentFilter} from "@/utils/assignments";
|
||||||
|
import useUserBalance from "@/hooks/useUserBalance";
|
||||||
|
import AssignmentsPage from "../views/AssignmentsPage";
|
||||||
|
import StudentPerformanceList from "./StudentPerformanceList";
|
||||||
|
import StudentPerformancePage from "./StudentPerformancePage";
|
||||||
|
import MasterStatisticalPage from "./MasterStatisticalPage";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: MasterCorporateUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const studentHash = {
|
||||||
|
type: "student",
|
||||||
|
size: 25,
|
||||||
|
orderBy: "registrationDate",
|
||||||
|
};
|
||||||
|
|
||||||
|
const teacherHash = {
|
||||||
|
type: "teacher",
|
||||||
|
size: 25,
|
||||||
|
orderBy: "registrationDate",
|
||||||
|
};
|
||||||
|
|
||||||
|
const corporateHash = {
|
||||||
|
type: "corporate",
|
||||||
|
size: 25,
|
||||||
|
orderBy: "registrationDate",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MasterCorporateDashboard({user}: Props) {
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [corporateAssignments, setCorporateAssignments] = useState<(Assignment & {corporate?: CorporateUser})[]>([]);
|
||||||
|
|
||||||
|
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
||||||
|
|
||||||
|
const {users: students, total: totalStudents, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(studentHash);
|
||||||
|
const {users: teachers, total: totalTeachers, reload: reloadTeachers, isLoading: isTeachersLoading} = useUsers(teacherHash);
|
||||||
|
const {users: corporates, total: totalCorporate, reload: reloadCorporates, isLoading: isCorporatesLoading} = useUsers(corporateHash);
|
||||||
|
|
||||||
|
const {groups} = useGroups({admin: user.id, userType: user.type});
|
||||||
|
const {balance} = useUserBalance();
|
||||||
|
|
||||||
|
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
|
||||||
|
|
||||||
|
const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]);
|
||||||
|
const assignmentsUsers = useMemo(
|
||||||
|
() =>
|
||||||
|
[...students, ...teachers].filter((x) =>
|
||||||
|
!!selectedUser
|
||||||
|
? groups
|
||||||
|
.filter((g) => g.admin === selectedUser.id)
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(x.id) || false
|
||||||
|
: groups.flatMap((g) => g.participants).includes(x.id),
|
||||||
|
),
|
||||||
|
[groups, selectedUser, teachers, students],
|
||||||
|
);
|
||||||
|
|
||||||
|
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShowModal(!!selectedUser && router.asPath === "/");
|
||||||
|
}, [selectedUser, router.asPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCorporateAssignments(
|
||||||
|
assignments.filter(activeAssignmentFilter).map((a) => {
|
||||||
|
const assigner = [...teachers, ...corporates].find((x) => x.id === a.assigner);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...a,
|
||||||
|
corporate: assigner ? getCorporateUser(assigner, [...teachers, ...corporates], groups) : undefined,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [assignments, groups, teachers, corporates]);
|
||||||
|
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
||||||
|
|
||||||
|
const UserDisplay = (displayUser: User) => (
|
||||||
|
<div
|
||||||
|
onClick={() => setSelectedUser(displayUser)}
|
||||||
|
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
||||||
|
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
||||||
|
<div className="flex flex-col gap-1 items-start">
|
||||||
|
<span>{displayUser.name}</span>
|
||||||
|
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const GroupsList = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Groups ({groups.length})</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GroupList user={user} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (router.asPath === "/#studentsPerformance") return <StudentPerformancePage user={user} />;
|
||||||
|
if (router.asPath === "/#statistical") return <MasterStatisticalPage />;
|
||||||
|
if (router.asPath === "/#groups") return <GroupsList />;
|
||||||
|
|
||||||
|
if (router.asPath === "/#students")
|
||||||
|
return (
|
||||||
|
<UserList
|
||||||
|
user={user}
|
||||||
|
type="student"
|
||||||
|
renderHeader={(total) => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Students ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (router.asPath === "/#assignments")
|
||||||
|
return (
|
||||||
|
<AssignmentsPage
|
||||||
|
assignments={assignments}
|
||||||
|
corporateAssignments={corporateAssignments}
|
||||||
|
groups={assignmentsGroups}
|
||||||
|
user={user}
|
||||||
|
reloadAssignments={reloadAssignments}
|
||||||
|
isLoading={isAssignmentsLoading}
|
||||||
|
onBack={() => router.push("/")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (router.asPath === "/#corporate")
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (router.asPath === "/#students")
|
||||||
|
return (
|
||||||
|
<UserList
|
||||||
|
user={user}
|
||||||
|
type="student"
|
||||||
|
renderHeader={(total) => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Students ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (router.asPath === "/#teachers")
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
|
||||||
|
<>
|
||||||
|
{selectedUser && (
|
||||||
|
<div className="w-full flex flex-col gap-8">
|
||||||
|
<UserCard
|
||||||
|
maxUserAmount={
|
||||||
|
user.type === "mastercorporate"
|
||||||
|
? (user.corporateInformation?.companyInformation?.userAmount || 0) - balance
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
loggedInUser={user}
|
||||||
|
onClose={(shouldReload) => {
|
||||||
|
setSelectedUser(undefined);
|
||||||
|
if (shouldReload && selectedUser!.type === "student") reloadStudents();
|
||||||
|
if (shouldReload && selectedUser!.type === "teacher") reloadTeachers();
|
||||||
|
if (shouldReload && selectedUser!.type === "corporate") reloadCorporates();
|
||||||
|
}}
|
||||||
|
onViewStudents={
|
||||||
|
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
||||||
|
? () => {
|
||||||
|
appendUserFilters({
|
||||||
|
id: "view-students",
|
||||||
|
filter: (x: User) => x.type === "student",
|
||||||
|
});
|
||||||
|
appendUserFilters({
|
||||||
|
id: "belongs-to-admin",
|
||||||
|
filter: (x: User) =>
|
||||||
|
groups
|
||||||
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(x.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push("/list/users");
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onViewTeachers={
|
||||||
|
selectedUser.type === "corporate" || selectedUser.type === "student"
|
||||||
|
? () => {
|
||||||
|
appendUserFilters({
|
||||||
|
id: "view-teachers",
|
||||||
|
filter: (x: User) => x.type === "teacher",
|
||||||
|
});
|
||||||
|
appendUserFilters({
|
||||||
|
id: "belongs-to-admin",
|
||||||
|
filter: (x: User) =>
|
||||||
|
groups
|
||||||
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(x.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push("/list/users");
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
user={selectedUser}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<>
|
||||||
|
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center">
|
||||||
|
<IconCard
|
||||||
|
onClick={() => router.push("/#students")}
|
||||||
|
Icon={BsPersonFill}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
|
label="Students"
|
||||||
|
value={totalStudents}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
onClick={() => router.push("/#teachers")}
|
||||||
|
Icon={BsPencilSquare}
|
||||||
|
isLoading={isTeachersLoading}
|
||||||
|
label="Teachers"
|
||||||
|
value={totalTeachers}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsClipboard2Data}
|
||||||
|
label="Exams Performed"
|
||||||
|
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsPaperclip}
|
||||||
|
label="Average Level"
|
||||||
|
value={averageLevelCalculator(
|
||||||
|
students,
|
||||||
|
stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)),
|
||||||
|
).toFixed(1)}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard onClick={() => router.push("/#groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
|
||||||
|
<IconCard
|
||||||
|
Icon={BsPersonCheck}
|
||||||
|
label="User Balance"
|
||||||
|
value={`${balance}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsClock}
|
||||||
|
label="Expiration Date"
|
||||||
|
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
|
||||||
|
color="rose"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsBank}
|
||||||
|
label="Corporate Accounts"
|
||||||
|
value={totalCorporate}
|
||||||
|
isLoading={isCorporatesLoading}
|
||||||
|
color="purple"
|
||||||
|
onClick={() => router.push("/#corporate")}
|
||||||
|
/>
|
||||||
|
<IconCard Icon={BsBank} label="Corporate" value={totalCorporate} isLoading={isCorporatesLoading} color="purple" />
|
||||||
|
<IconCard
|
||||||
|
Icon={BsPersonFillGear}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
|
label="Student Performance"
|
||||||
|
value={students.length}
|
||||||
|
color="purple"
|
||||||
|
onClick={() => router.push("/#studentsPerformance")}
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsDatabase}
|
||||||
|
label="Master Statistical"
|
||||||
|
// value={masterCorporateUserGroups.length}
|
||||||
|
color="purple"
|
||||||
|
onClick={() => router.push("/#statistical")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
disabled={isAssignmentsLoading}
|
||||||
|
onClick={() => router.push("/#assignments")}
|
||||||
|
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||||
|
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
||||||
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
|
<span className="text-lg">Assignments</span>
|
||||||
|
<span className="font-semibold text-mti-purple-light">
|
||||||
|
{isAssignmentsLoading ? "Loading..." : assignments.filter((a) => !a.archived).length}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Latest students</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{students
|
||||||
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Latest teachers</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{teachers
|
||||||
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Highest level students</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{students
|
||||||
|
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Highest exam count students</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{students
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
||||||
|
)
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {CorporateUser} from "@/interfaces/user";
|
|
||||||
import {BsBank, BsPersonFill} from "react-icons/bs";
|
|
||||||
import IconCard from "./IconCard";
|
|
||||||
import useAssignmentsCorporates from "@/hooks/useAssignmentCorporates";
|
|
||||||
interface Props {
|
|
||||||
users: CorporateUser[];
|
|
||||||
}
|
|
||||||
const MasterStatistical = (props: Props) => {
|
|
||||||
const {users} = props;
|
|
||||||
|
|
||||||
const usersList = React.useMemo(() => users.map((x) => x.id), [users]);
|
|
||||||
|
|
||||||
const {assignments} = useAssignmentsCorporates({corporates: usersList});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap gap-2 items-center text-center">
|
|
||||||
<IconCard Icon={BsBank} label="Consolidate" value={0} color="purple" onClick={() => console.log("clicked")} />
|
|
||||||
{users.map((group) => (
|
|
||||||
<IconCard
|
|
||||||
key={group.id}
|
|
||||||
Icon={BsBank}
|
|
||||||
label={group.corporateInformation?.companyInformation?.name}
|
|
||||||
value={0}
|
|
||||||
color="purple"
|
|
||||||
onClick={() => console.log("clicked", group)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<IconCard onClick={() => console.log("clicked")} Icon={BsPersonFill} label="Consolidate Highest Student" color="purple" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MasterStatistical;
|
|
||||||
@@ -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())}
|
Start
|
||||||
className="h-full w-full !rounded-xl"
|
</Button>
|
||||||
variant="outline">
|
</div>
|
||||||
|
<div
|
||||||
|
data-tip="You have already started this assignment!"
|
||||||
|
className={clsx(
|
||||||
|
"-md:hidden h-full w-full max-w-[50%] cursor-pointer",
|
||||||
|
sessions.filter((x) => x.assignment?.id === assignment.id).length > 0 && "tooltip",
|
||||||
|
)}>
|
||||||
|
<Button
|
||||||
|
className={clsx("w-full h-full !rounded-xl")}
|
||||||
|
onClick={() => startAssignment(assignment)}
|
||||||
|
variant="outline"
|
||||||
|
disabled={sessions.filter((x) => x.assignment?.id === assignment.id).length > 0}>
|
||||||
Start
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
disabled={moment(assignment.startDate).isAfter(moment())}
|
|
||||||
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
|
||||||
onClick={() => startAssignment(assignment)}
|
|
||||||
variant="outline">
|
|
||||||
Start
|
|
||||||
</Button>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{assignment.results.map((r) => r.user).includes(user.id) && (
|
{assignment.results.map((r) => r.user).includes(user.id) && (
|
||||||
@@ -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,41 @@ 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) {
|
const studentHash = {
|
||||||
const [page, setPage] = useState("");
|
type: "student",
|
||||||
|
orderBy: "registrationDate",
|
||||||
|
size: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TeacherDashboard({user, linkedCorporate}: Props) {
|
||||||
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, total: totalStudents, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(studentHash);
|
||||||
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]);
|
||||||
|
|
||||||
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 +98,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 +105,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 +121,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,218 +143,36 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
return calculateAverageLevel(levels);
|
return calculateAverageLevel(levels);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AssignmentsPage = () => {
|
if (router.asPath === "/#students")
|
||||||
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 (
|
return (
|
||||||
<>
|
<UserList
|
||||||
<AssignmentView
|
user={user}
|
||||||
isOpen={!!selectedAssignment && !isCreatingAssignment}
|
type="student"
|
||||||
onClose={() => {
|
renderHeader={(total) => (
|
||||||
setSelectedAssignment(undefined);
|
<div className="flex flex-col gap-4">
|
||||||
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
|
<div
|
||||||
onClick={() => setIsCreatingAssignment(true)}
|
onClick={() => router.push("/")}
|
||||||
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">
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
<BsPlus className="text-6xl" />
|
<BsArrowLeft className="text-xl" />
|
||||||
<span className="text-lg">New Assignment</span>
|
<span>Back</span>
|
||||||
</div>
|
</div>
|
||||||
{assignments.filter(futureFilter).map((a) => (
|
<h2 className="text-2xl font-semibold">Students ({total})</h2>
|
||||||
<AssignmentCard
|
|
||||||
{...a}
|
|
||||||
users={users}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedAssignment(a);
|
|
||||||
setIsCreatingAssignment(true);
|
|
||||||
}}
|
|
||||||
key={a.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</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 = () => (
|
|
||||||
<>
|
|
||||||
{corporateUserToShow && (
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<section
|
|
||||||
className={clsx(
|
|
||||||
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center",
|
|
||||||
!!corporateUserToShow && "mt-12 xl:mt-6",
|
|
||||||
)}>
|
|
||||||
<IconCard
|
|
||||||
onClick={() => setPage("students")}
|
|
||||||
Icon={BsPersonFill}
|
|
||||||
label="Students"
|
|
||||||
value={users.filter(studentFilter).length}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsClipboard2Data}
|
|
||||||
label="Exams Performed"
|
|
||||||
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsPaperclip}
|
|
||||||
label="Average Level"
|
|
||||||
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
{checkAccess(user, ["teacher", "developer"], permissions, "viewGroup") && (
|
|
||||||
<IconCard
|
|
||||||
Icon={BsPeople}
|
|
||||||
label="Groups"
|
|
||||||
value={groups.filter((x) => x.admin === user.id).length}
|
|
||||||
color="purple"
|
|
||||||
onClick={() => setPage("groups")}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<div
|
/>
|
||||||
onClick={() => setPage("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">
|
if (router.asPath === "/#assignments")
|
||||||
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
return (
|
||||||
<span className="flex flex-col gap-1 items-center text-xl">
|
<AssignmentsPage
|
||||||
<span className="text-lg">Assignments</span>
|
assignments={assignments}
|
||||||
<span className="font-semibold text-mti-purple-light">{assignments.filter((a) => !a.archived).length}</span>
|
groups={assignmentsGroups}
|
||||||
</span>
|
user={user}
|
||||||
</div>
|
reloadAssignments={reloadAssignments}
|
||||||
</section>
|
isLoading={isAssignmentsLoading}
|
||||||
|
onBack={() => router.push("/")}
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
|
/>
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
);
|
||||||
<span className="p-4">Latest students</span>
|
if (router.asPath === "/#groups") return <GroupsList />;
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
|
||||||
{users
|
|
||||||
.filter(studentFilter)
|
|
||||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
|
||||||
.map((x) => (
|
|
||||||
<UserDisplay key={x.id} {...x} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
|
||||||
<span className="p-4">Highest level students</span>
|
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
|
||||||
{users
|
|
||||||
.filter(studentFilter)
|
|
||||||
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
|
||||||
.map((x) => (
|
|
||||||
<UserDisplay key={x.id} {...x} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
|
||||||
<span className="p-4">Highest exam count students</span>
|
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
|
||||||
{users
|
|
||||||
.filter(studentFilter)
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
|
||||||
)
|
|
||||||
.map((x) => (
|
|
||||||
<UserDisplay key={x.id} {...x} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -388,22 +184,143 @@ 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 />}
|
|
||||||
{page === "groups" && <GroupsList />}
|
<>
|
||||||
{page === "assignments" && <AssignmentsPage />}
|
{linkedCorporate && (
|
||||||
{page === "" && <DefaultDashboard />}
|
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
|
||||||
|
Linked to: <b>{linkedCorporate?.corporateInformation?.companyInformation.name || linkedCorporate.name}</b>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<section
|
||||||
|
className={clsx(
|
||||||
|
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center",
|
||||||
|
!!linkedCorporate && "mt-12 xl:mt-6",
|
||||||
|
)}>
|
||||||
|
<IconCard
|
||||||
|
onClick={() => router.push("/#students")}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
|
Icon={BsPersonFill}
|
||||||
|
label="Students"
|
||||||
|
value={totalStudents}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsClipboard2Data}
|
||||||
|
label="Exams Performed"
|
||||||
|
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsPaperclip}
|
||||||
|
label="Average Level"
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
|
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
{checkAccess(user, ["teacher", "developer"], permissions, "viewGroup") && (
|
||||||
|
<IconCard
|
||||||
|
Icon={BsPeople}
|
||||||
|
label="Groups"
|
||||||
|
value={groups.filter((x) => x.admin === user.id).length}
|
||||||
|
color="purple"
|
||||||
|
onClick={() => router.push("/#groups")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
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">
|
||||||
|
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
||||||
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
|
<span className="text-lg">Assignments</span>
|
||||||
|
<span className="font-semibold text-mti-purple-light">{assignments.filter((a) => !a.archived).length}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Latest students</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{students
|
||||||
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Highest level students</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{students
|
||||||
|
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Highest exam count students</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{students
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
||||||
|
)
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
183
src/dashboards/views/AssignmentsPage.tsx
Normal file
183
src/dashboards/views/AssignmentsPage.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
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[];
|
||||||
|
isLoading: boolean;
|
||||||
|
user: User;
|
||||||
|
onBack: () => void;
|
||||||
|
reloadAssignments: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssignmentsPage({assignments, corporateAssignments, user, groups, isLoading, onBack, reloadAssignments}: Props) {
|
||||||
|
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
|
||||||
|
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
||||||
|
|
||||||
|
const {users} = useUsers();
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -24,8 +25,10 @@ import {LevelScore} from "@/constants/ielts";
|
|||||||
import {getLevelScore} from "@/utils/score";
|
import {getLevelScore} from "@/utils/score";
|
||||||
import {capitalize} from "lodash";
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -155,26 +159,24 @@ export default function Finish({user, scores, modules, information, solutions, i
|
|||||||
)}
|
)}
|
||||||
{modules.includes("writing") && (
|
{modules.includes("writing") && (
|
||||||
<div className="flex w-full justify-between items-center">
|
<div className="flex w-full justify-between items-center">
|
||||||
<div
|
<div
|
||||||
onClick={() => setSelectedModule("writing")}
|
onClick={() => setSelectedModule("writing")}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"hover:bg-ielts-writing flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
|
"hover:bg-ielts-writing flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
|
||||||
selectedModule === "writing" ? "bg-ielts-writing text-white" : "bg-mti-gray-smoke text-ielts-writing",
|
selectedModule === "writing" ? "bg-ielts-writing text-white" : "bg-mti-gray-smoke text-ielts-writing",
|
||||||
)}>
|
)}>
|
||||||
<BsPen className="h-6 w-6" />
|
<BsPen className="h-6 w-6" />
|
||||||
<span className="font-semibold">Writing</span>
|
<span className="font-semibold">Writing</span>
|
||||||
</div>
|
|
||||||
{aiUsage >= 50 && user.type !== "student" && (
|
|
||||||
<div className={clsx(
|
|
||||||
"flex items-center justify-center border px-3 h-full rounded",
|
|
||||||
{
|
|
||||||
'bg-orange-100 border-orange-400 text-orange-700': aiUsage < 80,
|
|
||||||
'bg-red-100 border-red-400 text-red-700': aiUsage >= 80,
|
|
||||||
}
|
|
||||||
)}>
|
|
||||||
<span className="text-xs">AI Usage</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{aiUsage >= 50 && user.type !== "student" && (
|
||||||
|
<div
|
||||||
|
className={clsx("flex items-center justify-center border px-3 h-full rounded", {
|
||||||
|
"bg-orange-100 border-orange-400 text-orange-700": aiUsage < 80,
|
||||||
|
"bg-red-100 border-red-400 text-red-700": aiUsage >= 80,
|
||||||
|
})}>
|
||||||
|
<span className="text-xs">AI Usage</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{modules.includes("speaking") && (
|
{modules.includes("speaking") && (
|
||||||
@@ -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 paragraphWords = line.split(/(\s+)/);
|
||||||
|
return paragraphWords.map((word, wordIndex) => {
|
||||||
|
if (lineIndex !== 0 && wordIndex == 0 && lineIndex < paragraphs.length) {
|
||||||
|
betweenParagraphs[lineIndex - 1][1] = word;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wordIndex == paragraphWords.length - 1 && lineIndex < paragraphs.length) {
|
||||||
|
betweenParagraphs[lineIndex][0] = word;
|
||||||
|
}
|
||||||
|
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.textContent = word;
|
if (wordIndex === 0 && bTag) {
|
||||||
p.appendChild(span);
|
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'));
|
||||||
offscreenElement.appendChild(p);
|
|
||||||
|
|
||||||
if (pIndex < paragraphs.length - 1) {
|
|
||||||
const gap = document.createElement('div');
|
|
||||||
gap.style.height = '16px'; // gap-4
|
|
||||||
offscreenElement.appendChild(gap);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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');
|
||||||
const rect = span.getBoundingClientRect();
|
|
||||||
const top = rect.top;
|
|
||||||
|
|
||||||
if (currentLineTop === undefined || top > currentLineTop) {
|
let betweenIndex = 0;
|
||||||
if (currentLineTop !== undefined) {
|
const addBreaksTo: number[] = [];
|
||||||
currentLine++;
|
spans.forEach((span, index) => {
|
||||||
}
|
const rect = span.getBoundingClientRect();
|
||||||
currentLineTop = top;
|
const top = rect.top;
|
||||||
}
|
|
||||||
|
|
||||||
if (contextWord && contextWordLine === null && span.textContent?.includes(contextWord)) {
|
if (
|
||||||
contextWordLine = currentLine;
|
betweenIndex < paragraphs.length - 1 &&
|
||||||
}
|
span.textContent === betweenParagraphs[betweenIndex][1] &&
|
||||||
});
|
spans[index - 1].textContent === betweenParagraphs[betweenIndex][0]
|
||||||
} else if (element.tagName === 'DIV') { // Gap
|
) {
|
||||||
|
addBreaksTo.push(currentLine);
|
||||||
|
betweenIndex = betweenIndex + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLineTop !== undefined && top > currentLineTop) {
|
||||||
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 (
|
|
||||||
<div className="flex flex-col gap-2 w-full">
|
|
||||||
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
|
||||||
{!!part.context &&
|
|
||||||
part.context
|
|
||||||
.split(/\n|(\\n)/g)
|
|
||||||
.filter((x) => x && x.length > 0 && x !== "\\n")
|
|
||||||
.map((line, index) => (
|
|
||||||
<Fragment key={index}>
|
|
||||||
<p key={index}>{line}</p>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}*/
|
|
||||||
|
|
||||||
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">
|
||||||
<div className="flex mt-2">
|
{lineNumbers.map(num => (
|
||||||
<div ref={textRef} className="h-fit ml-2 flex flex-col gap-4">
|
<>
|
||||||
{part.context!.split('\n\n').map((line, index) => {
|
<div key={num} className="text-gray-400 flex justify-end" style={{ lineHeight: `${lineHeight}px` }}>
|
||||||
return <p key={`line-${index}`}><span className="mr-6">{index + 1}</span>{line}</p>
|
{num}
|
||||||
})}
|
</div>
|
||||||
</div>
|
{/* Do not delete the space between the span or else the lines get messed up */}
|
||||||
|
{addBreaksTo.includes(num) && <span className={`h-[${lineHeight}px] whitespace-pre-wrap`}> </span>}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div ref={textRef} className="h-fit whitespace-pre-wrap ml-2">
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: part.context! }} />
|
||||||
</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 = () => {
|
||||||
|
setNextExerciseCalled(true);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const nextExercise = () => {
|
||||||
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(
|
|
||||||
`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,294 @@ 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)
|
||||||
);
|
}
|
||||||
}
|
return acc;
|
||||||
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
|
}, 0) + (questionIndex + 1);
|
||||||
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
|
|
||||||
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">
|
<>
|
||||||
<h4 className="text-xl font-semibold">
|
<div className="flex flex-col w-full gap-2">
|
||||||
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
{textRender && !textRenderDisabled ? (
|
||||||
</h4>
|
<>
|
||||||
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
|
<h4 className="text-xl font-semibold">
|
||||||
|
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
||||||
|
</h4>
|
||||||
|
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<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
|
||||||
|
part={exam.parts[partIndex]}
|
||||||
|
contextWords={contextWords}
|
||||||
|
setContextWordLines={setContextWordLines}
|
||||||
|
setTotalLines={setTotalLines}
|
||||||
|
/>}
|
||||||
|
</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>
|
</div>
|
||||||
<TextComponent
|
)}
|
||||||
part={exam.parts[partIndex]}
|
</>
|
||||||
contextWord={contextWord}
|
|
||||||
setContextWordLine={setContextWordLine}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
</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) {
|
||||||
return userSolution?.solutions.length === exercise.questions.length;
|
case 'multipleChoice':
|
||||||
}
|
return userSolution?.solutions.length === exercise.questions.length;
|
||||||
if (exercise.type === "fillBlanks") {
|
case '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 {
|
useEffect(() => {
|
||||||
blankQuestions: !allSolutionsCorrectLength,
|
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
|
||||||
finishingWhat: "part",
|
|
||||||
onClose: partIndex !== exam.parts.length - 1 ? (
|
const findMatch = (index: number) => {
|
||||||
function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } }
|
if (currentExercise && currentExercise.type === "multipleChoice" && currentExercise!.questions[index]) {
|
||||||
) : function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); onFinish(userSolutions); } else { setShowQuestionsModal(false) } }
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [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,41 @@
|
|||||||
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?: string; page?: number; size?: number; orderBy?: string; direction?: "asc" | "desc"}) {
|
||||||
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 getData = () => {
|
const getData = () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (!!props)
|
||||||
|
Object.keys(props).forEach((key) => {
|
||||||
|
if (props[key as keyof typeof props] !== undefined) params.append(key, props[key as keyof typeof props]!.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(params.toString());
|
||||||
|
|
||||||
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));
|
||||||
};
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
useEffect(getData, [props?.page, props?.size, props?.type, props?.orderBy, props?.direction]);
|
||||||
|
|
||||||
useEffect(getData, []);
|
return {users, total, isLoading, isError, reload: getData};
|
||||||
|
|
||||||
return {users, isLoading, isError, reload: getData};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Module } from ".";
|
import {Module} from ".";
|
||||||
|
|
||||||
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
||||||
export type Variant = "full" | "partial";
|
export type Variant = "full" | "partial";
|
||||||
@@ -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 {
|
||||||
@@ -99,24 +103,19 @@ export type Exercise =
|
|||||||
export interface Evaluation {
|
export interface Evaluation {
|
||||||
comment: string;
|
comment: string;
|
||||||
overall: number;
|
overall: number;
|
||||||
task_response: { [key: string]: number | { grade: number; comment: string } };
|
task_response: {[key: string]: number | {grade: number; comment: string}};
|
||||||
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}`;
|
||||||
|
|
||||||
type InteractivePerfectAnswerType = { [key in InteractivePerfectAnswerKey]: { answer: string } };
|
type InteractivePerfectAnswerType = {[key in InteractivePerfectAnswerKey]: {answer: string}};
|
||||||
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,
|
|
||||||
InteractivePerfectAnswerType,
|
|
||||||
InteractiveTranscriptType,
|
|
||||||
InteractiveFixedTextType { }
|
|
||||||
|
|
||||||
|
interface InteractiveSpeakingEvaluation extends Evaluation, InteractivePerfectAnswerType, InteractiveTranscriptType, InteractiveFixedTextType {}
|
||||||
|
|
||||||
interface SpeakingEvaluation extends CommonEvaluation {
|
interface SpeakingEvaluation extends CommonEvaluation {
|
||||||
perfect_answer_1?: string;
|
perfect_answer_1?: string;
|
||||||
@@ -189,10 +188,10 @@ export interface InteractiveSpeakingExercise {
|
|||||||
first_title?: string;
|
first_title?: string;
|
||||||
second_title?: string;
|
second_title?: string;
|
||||||
text: string;
|
text: string;
|
||||||
prompts: { text: string; video_url: string }[];
|
prompts: {text: string; video_url: string}[];
|
||||||
userSolutions: {
|
userSolutions: {
|
||||||
id: string;
|
id: string;
|
||||||
solution: { questionIndex: number; question: string; answer: string }[];
|
solution: {questionIndex: number; question: string; answer: string}[];
|
||||||
evaluation?: InteractiveSpeakingEvaluation;
|
evaluation?: InteractiveSpeakingEvaluation;
|
||||||
}[];
|
}[];
|
||||||
topic?: string;
|
topic?: string;
|
||||||
@@ -208,14 +207,14 @@ export interface FillBlanksMCOption {
|
|||||||
B: string;
|
B: string;
|
||||||
C: string;
|
C: string;
|
||||||
D: string;
|
D: string;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FillBlanksExercise {
|
export interface FillBlanksExercise {
|
||||||
prompt: string; // *EXAMPLE: "Complete the summary below. Click a blank to select the corresponding word for it."
|
prompt: string; // *EXAMPLE: "Complete the summary below. Click a blank to select the corresponding word for it."
|
||||||
type: "fillBlanks";
|
type: "fillBlanks";
|
||||||
id: string;
|
id: string;
|
||||||
words: (string | { letter: string; word: string } | FillBlanksMCOption)[]; // *EXAMPLE: ["preserve", "unaware"]
|
words: (string | {letter: string; word: string} | FillBlanksMCOption)[]; // *EXAMPLE: ["preserve", "unaware"]
|
||||||
text: string; // *EXAMPLE: "They tried to {{1}} burning"
|
text: string; // *EXAMPLE: "They tried to {{1}} burning"
|
||||||
allowRepetition?: boolean;
|
allowRepetition?: boolean;
|
||||||
solutions: {
|
solutions: {
|
||||||
@@ -234,7 +233,7 @@ export interface TrueFalseExercise {
|
|||||||
id: string;
|
id: string;
|
||||||
prompt: string; // *EXAMPLE: "Select the appropriate option."
|
prompt: string; // *EXAMPLE: "Select the appropriate option."
|
||||||
questions: TrueFalseQuestion[];
|
questions: TrueFalseQuestion[];
|
||||||
userSolutions: { id: string; solution: "true" | "false" | "not_given" }[];
|
userSolutions: {id: string; solution: "true" | "false" | "not_given"}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrueFalseQuestion {
|
export interface TrueFalseQuestion {
|
||||||
@@ -263,7 +262,7 @@ export interface MatchSentencesExercise {
|
|||||||
type: "matchSentences";
|
type: "matchSentences";
|
||||||
id: string;
|
id: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
userSolutions: { question: string; option: string }[];
|
userSolutions: {question: string; option: string}[];
|
||||||
sentences: MatchSentenceExerciseSentence[];
|
sentences: MatchSentenceExerciseSentence[];
|
||||||
allowRepetition: boolean;
|
allowRepetition: boolean;
|
||||||
options: MatchSentenceExerciseOption[];
|
options: MatchSentenceExerciseOption[];
|
||||||
@@ -286,7 +285,7 @@ export interface MultipleChoiceExercise {
|
|||||||
id: string;
|
id: string;
|
||||||
prompt: string; // *EXAMPLE: "Select the appropriate option."
|
prompt: string; // *EXAMPLE: "Select the appropriate option."
|
||||||
questions: MultipleChoiceQuestion[];
|
questions: MultipleChoiceQuestion[];
|
||||||
userSolutions: { question: string; option: string }[];
|
userSolutions: {question: string; option: string}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MultipleChoiceQuestion {
|
export interface MultipleChoiceQuestion {
|
||||||
@@ -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};
|
||||||
|
|||||||
@@ -1,195 +1,168 @@
|
|||||||
import { Module } from ".";
|
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 {
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
profilePicture: string;
|
profilePicture: string;
|
||||||
id: string;
|
id: string;
|
||||||
isFirstLogin: boolean;
|
isFirstLogin: boolean;
|
||||||
focus: "academic" | "general";
|
focus: "academic" | "general";
|
||||||
levels: { [key in Module]: number };
|
levels: {[key in Module]: number};
|
||||||
desiredLevels: { [key in Module]: number };
|
desiredLevels: {[key in Module]: number};
|
||||||
type: Type;
|
type: Type;
|
||||||
bio: string;
|
bio: string;
|
||||||
isVerified: boolean;
|
isVerified: boolean;
|
||||||
subscriptionExpirationDate?: null | Date;
|
subscriptionExpirationDate?: null | Date;
|
||||||
registrationDate?: Date;
|
registrationDate?: Date;
|
||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
permissions: PermissionType[];
|
permissions: PermissionType[];
|
||||||
lastLogin?: Date;
|
lastLogin?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StudentUser extends BasicUser {
|
export interface StudentUser extends BasicUser {
|
||||||
type: "student";
|
type: "student";
|
||||||
preferredGender?: InstructorGender;
|
studentID?: string;
|
||||||
demographicInformation?: DemographicInformation;
|
preferredGender?: InstructorGender;
|
||||||
preferredTopics?: string[];
|
demographicInformation?: DemographicInformation;
|
||||||
|
preferredTopics?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeacherUser extends BasicUser {
|
export interface TeacherUser extends BasicUser {
|
||||||
type: "teacher";
|
type: "teacher";
|
||||||
demographicInformation?: DemographicInformation;
|
demographicInformation?: DemographicInformation;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CorporateUser extends BasicUser {
|
export interface CorporateUser extends BasicUser {
|
||||||
type: "corporate";
|
type: "corporate";
|
||||||
corporateInformation: CorporateInformation;
|
corporateInformation: CorporateInformation;
|
||||||
demographicInformation?: DemographicCorporateInformation;
|
demographicInformation?: DemographicCorporateInformation;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MasterCorporateUser extends BasicUser {
|
export interface MasterCorporateUser extends BasicUser {
|
||||||
type: "mastercorporate";
|
type: "mastercorporate";
|
||||||
corporateInformation: CorporateInformation;
|
corporateInformation: CorporateInformation;
|
||||||
demographicInformation?: DemographicCorporateInformation;
|
demographicInformation?: DemographicCorporateInformation;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentUser extends BasicUser {
|
export interface AgentUser extends BasicUser {
|
||||||
type: "agent";
|
type: "agent";
|
||||||
agentInformation: AgentInformation;
|
agentInformation: AgentInformation;
|
||||||
demographicInformation?: DemographicInformation;
|
demographicInformation?: DemographicInformation;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUser extends BasicUser {
|
export interface AdminUser extends BasicUser {
|
||||||
type: "admin";
|
type: "admin";
|
||||||
demographicInformation?: DemographicInformation;
|
demographicInformation?: DemographicInformation;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeveloperUser extends BasicUser {
|
export interface DeveloperUser extends BasicUser {
|
||||||
type: "developer";
|
type: "developer";
|
||||||
preferredGender?: InstructorGender;
|
preferredGender?: InstructorGender;
|
||||||
demographicInformation?: DemographicInformation;
|
demographicInformation?: DemographicInformation;
|
||||||
preferredTopics?: string[];
|
preferredTopics?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CorporateInformation {
|
export interface CorporateInformation {
|
||||||
companyInformation: CompanyInformation;
|
companyInformation: CompanyInformation;
|
||||||
monthlyDuration: number;
|
monthlyDuration: number;
|
||||||
payment?: {
|
payment?: {
|
||||||
value: number;
|
value: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
commission: number;
|
commission: number;
|
||||||
};
|
};
|
||||||
referralAgent?: string;
|
referralAgent?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentInformation {
|
export interface AgentInformation {
|
||||||
companyName: string;
|
companyName: string;
|
||||||
commercialRegistration: string;
|
commercialRegistration: string;
|
||||||
companyArabName?: string;
|
companyArabName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompanyInformation {
|
export interface CompanyInformation {
|
||||||
name: string;
|
name: string;
|
||||||
userAmount: number;
|
userAmount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DemographicInformation {
|
export interface DemographicInformation {
|
||||||
country: string;
|
country: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
gender: Gender;
|
gender: Gender;
|
||||||
employment: EmploymentStatus;
|
employment: EmploymentStatus;
|
||||||
passport_id?: string;
|
passport_id?: string;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DemographicCorporateInformation {
|
export interface DemographicCorporateInformation {
|
||||||
country: string;
|
country: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
gender: Gender;
|
gender: Gender;
|
||||||
position: string;
|
position: string;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
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"
|
{status: "student", label: "Student"},
|
||||||
| "self-employed"
|
{status: "employed", label: "Employed"},
|
||||||
| "unemployed"
|
{status: "unemployed", label: "Unemployed"},
|
||||||
| "retired"
|
{status: "self-employed", label: "Self-employed"},
|
||||||
| "other";
|
{status: "retired", label: "Retired"},
|
||||||
export const EMPLOYMENT_STATUS: { status: EmploymentStatus; label: string }[] =
|
{status: "other", label: "Other"},
|
||||||
[
|
];
|
||||||
{ status: "student", label: "Student" },
|
|
||||||
{ status: "employed", label: "Employed" },
|
|
||||||
{ status: "unemployed", label: "Unemployed" },
|
|
||||||
{ status: "self-employed", label: "Self-employed" },
|
|
||||||
{ status: "retired", label: "Retired" },
|
|
||||||
{ status: "other", label: "Other" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface Stat {
|
export interface Stat {
|
||||||
id: string;
|
id: string;
|
||||||
user: string;
|
user: string;
|
||||||
exam: string;
|
exam: string;
|
||||||
exercise: string;
|
exercise: string;
|
||||||
session: string;
|
session: string;
|
||||||
date: number;
|
date: number;
|
||||||
module: Module;
|
module: Module;
|
||||||
solutions: any[];
|
solutions: any[];
|
||||||
type: string;
|
type: string;
|
||||||
timeSpent?: number;
|
timeSpent?: number;
|
||||||
inactivity?: number;
|
inactivity?: number;
|
||||||
assignment?: string;
|
assignment?: string;
|
||||||
score: {
|
score: {
|
||||||
correct: number;
|
correct: number;
|
||||||
total: number;
|
total: number;
|
||||||
missing: number;
|
missing: number;
|
||||||
};
|
};
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
shuffleMaps?: ShuffleMap[];
|
shuffleMaps?: ShuffleMap[];
|
||||||
pdf?: {
|
pdf?: {
|
||||||
path: string;
|
path: string;
|
||||||
version: string;
|
version: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Group {
|
export interface Group {
|
||||||
admin: string;
|
admin: string;
|
||||||
name: string;
|
name: string;
|
||||||
participants: string[];
|
participants: string[];
|
||||||
id: string;
|
id: string;
|
||||||
disableEditing?: boolean;
|
disableEditing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Code {
|
export interface Code {
|
||||||
code: string;
|
id: string;
|
||||||
creator: string;
|
code: string;
|
||||||
expiryDate: Date;
|
creator: string;
|
||||||
type: Type;
|
expiryDate: Date;
|
||||||
creationDate?: string;
|
type: Type;
|
||||||
userId?: string;
|
creationDate?: string;
|
||||||
email?: string;
|
userId?: string;
|
||||||
name?: string;
|
email?: string;
|
||||||
passport_id?: string;
|
name?: 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",
|
|
||||||
];
|
|
||||||
30
src/lib/mongodb.ts
Normal file
30
src/lib/mongodb.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import {MongoClient} from "mongodb";
|
||||||
|
|
||||||
|
if (!process.env.MONGODB_URI) {
|
||||||
|
throw new Error('Invalid/Missing environment variable: "MONGODB_URI"');
|
||||||
|
}
|
||||||
|
|
||||||
|
const uri = process.env.MONGODB_URI;
|
||||||
|
const options = {};
|
||||||
|
|
||||||
|
let client: MongoClient;
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
// In development mode, use a global variable so that the value
|
||||||
|
// is preserved across module reloads caused by HMR (Hot Module Replacement).
|
||||||
|
let globalWithMongo = global as typeof globalThis & {
|
||||||
|
_mongoClient?: MongoClient;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!globalWithMongo._mongoClient) {
|
||||||
|
globalWithMongo._mongoClient = new MongoClient(uri, options);
|
||||||
|
}
|
||||||
|
client = globalWithMongo._mongoClient;
|
||||||
|
} else {
|
||||||
|
// In production mode, it's best to not use a global variable.
|
||||||
|
client = new MongoClient(uri, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a module-scoped MongoClient. By doing this in a
|
||||||
|
// separate module, the client can be shared across functions.
|
||||||
|
export default client;
|
||||||
5
src/mongodb.d.ts
vendored
Normal file
5
src/mongodb.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import {MongoClient} from "mongodb";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var _mongoClientPromise: Promise<MongoClient>;
|
||||||
|
}
|
||||||
@@ -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,11 +247,17 @@ 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)
|
||||||
<option key={type} value={type}>
|
.filter((x) => {
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
|
||||||
</option>
|
// 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}>
|
||||||
|
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
<Button className="my-auto" onClick={makeUsers} disabled={infos.length === 0}>
|
<Button className="my-auto" onClick={makeUsers} disabled={infos.length === 0}>
|
||||||
|
|||||||
@@ -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,223 +1,301 @@
|
|||||||
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";
|
||||||
import { Module } from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import { Exam } from "@/interfaces/exam";
|
import {Exam} from "@/interfaces/exam";
|
||||||
import { Type, User } from "@/interfaces/user";
|
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 CLASSES: { [key in Module]: string } = {
|
const searchFields = [["module"], ["id"], ["createdBy"]];
|
||||||
reading: "text-ielts-reading",
|
|
||||||
listening: "text-ielts-listening",
|
const CLASSES: {[key in Module]: string} = {
|
||||||
speaking: "text-ielts-speaking",
|
reading: "text-ielts-reading",
|
||||||
writing: "text-ielts-writing",
|
listening: "text-ielts-listening",
|
||||||
level: "text-ielts-level",
|
speaking: "text-ielts-speaking",
|
||||||
|
writing: "text-ielts-writing",
|
||||||
|
level: "text-ielts-level",
|
||||||
};
|
};
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<Exam>();
|
const columnHelper = createColumnHelper<Exam>();
|
||||||
|
|
||||||
export default function ExamList({ user }: { user: User }) {
|
const ExamOwnerSelector = ({options, exam, onSave}: {options: User[]; exam: Exam; onSave: (owners: string[]) => void}) => {
|
||||||
const { exams, reload } = useExams();
|
const [owners, setOwners] = useState(exam.owners || []);
|
||||||
const { users } = useUsers();
|
|
||||||
|
|
||||||
const parsedExams = useMemo(() => {
|
return (
|
||||||
return exams.map((exam) => {
|
<div className="w-full flex flex-col gap-4">
|
||||||
if (exam.createdBy) {
|
<div className="grid grid-cols-4 mt-4">
|
||||||
const user = users.find((u) => u.id === exam.createdBy);
|
{options.map((c) => (
|
||||||
if (!user) return exam;
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
export default function ExamList({user}: {user: User}) {
|
||||||
...exam,
|
const [selectedExam, setSelectedExam] = useState<Exam>();
|
||||||
createdBy: user.type === "developer" ? "system" : user.name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return exam;
|
const {exams, reload} = useExams();
|
||||||
});
|
const {users} = useUsers();
|
||||||
}, [exams, users]);
|
const {groups} = useGroups({admin: user?.id, userType: user?.type});
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
const filteredCorporates = useMemo(() => {
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
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 router = useRouter();
|
const parsedExams = useMemo(() => {
|
||||||
|
return exams.map((exam) => {
|
||||||
|
if (exam.createdBy) {
|
||||||
|
const user = users.find((u) => u.id === exam.createdBy);
|
||||||
|
if (!user) return exam;
|
||||||
|
|
||||||
const loadExam = async (module: Module, examId: string) => {
|
return {
|
||||||
const exam = await getExamById(module, examId.trim());
|
...exam,
|
||||||
if (!exam) {
|
createdBy: user.type === "developer" ? "system" : user.name,
|
||||||
toast.error(
|
};
|
||||||
"Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID",
|
}
|
||||||
{
|
|
||||||
toastId: "invalid-exam-id",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
return exam;
|
||||||
}
|
});
|
||||||
|
}, [exams, users]);
|
||||||
|
|
||||||
setExams([exam]);
|
const {rows: filteredRows, renderSearch} = useListSearch<Exam>(searchFields, parsedExams);
|
||||||
setSelectedModules([module]);
|
|
||||||
|
|
||||||
router.push("/exercises");
|
const setExams = useExamStore((state) => state.setExams);
|
||||||
};
|
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||||
|
|
||||||
const deleteExam = async (exam: Exam) => {
|
const router = useRouter();
|
||||||
if (
|
|
||||||
!confirm(
|
|
||||||
`Are you sure you want to delete this ${capitalize(exam.module)} exam?`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
axios
|
const loadExam = async (module: Module, examId: string) => {
|
||||||
.delete(`/api/exam/${exam.module}/${exam.id}`)
|
const exam = await getExamById(module, examId.trim());
|
||||||
.then(() => toast.success(`Deleted the "${exam.id}" exam`))
|
if (!exam) {
|
||||||
.catch((reason) => {
|
toast.error("Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID", {
|
||||||
if (reason.response.status === 404) {
|
toastId: "invalid-exam-id",
|
||||||
toast.error("Exam not found!");
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reason.response.status === 403) {
|
return;
|
||||||
toast.error("You do not have permission to delete this exam!");
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.error("Something went wrong, please try again later.");
|
setExams([exam]);
|
||||||
})
|
setSelectedModules([module]);
|
||||||
.finally(reload);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTotalExercises = (exam: Exam) => {
|
router.push("/exercises");
|
||||||
if (
|
};
|
||||||
exam.module === "reading" ||
|
|
||||||
exam.module === "listening" ||
|
|
||||||
exam.module === "level"
|
|
||||||
) {
|
|
||||||
return countExercises(exam.parts.flatMap((x) => x.exercises));
|
|
||||||
}
|
|
||||||
|
|
||||||
return countExercises(exam.exercises);
|
const privatizeExam = async (exam: Exam) => {
|
||||||
};
|
if (!confirm(`Are you sure you want to make this ${capitalize(exam.module)} exam ${exam.private ? "public" : "private"}?`)) return;
|
||||||
|
|
||||||
const defaultColumns = [
|
axios
|
||||||
columnHelper.accessor("id", {
|
.patch(`/api/exam/${exam.module}/${exam.id}`, {private: !exam.private})
|
||||||
header: "ID",
|
.then(() => toast.success(`Updated the "${exam.id}" exam`))
|
||||||
cell: (info) => info.getValue(),
|
.catch((reason) => {
|
||||||
}),
|
if (reason.response.status === 404) {
|
||||||
columnHelper.accessor("module", {
|
toast.error("Exam not found!");
|
||||||
header: "Module",
|
return;
|
||||||
cell: (info) => (
|
}
|
||||||
<span className={CLASSES[info.getValue()]}>
|
|
||||||
{capitalize(info.getValue())}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
columnHelper.accessor((x) => getTotalExercises(x), {
|
|
||||||
header: "Exercises",
|
|
||||||
cell: (info) => info.getValue(),
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("minTimer", {
|
|
||||||
header: "Timer",
|
|
||||||
cell: (info) => <>{info.getValue()} minute(s)</>,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("createdAt", {
|
|
||||||
header: "Created At",
|
|
||||||
cell: (info) => {
|
|
||||||
const value = info.getValue();
|
|
||||||
if (value) {
|
|
||||||
return new Date(value).toLocaleDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
if (reason.response.status === 403) {
|
||||||
},
|
toast.error("You do not have permission to update this exam!");
|
||||||
}),
|
return;
|
||||||
columnHelper.accessor("createdBy", {
|
}
|
||||||
header: "Created By",
|
|
||||||
cell: (info) => info.getValue(),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
header: "",
|
|
||||||
id: "actions",
|
|
||||||
cell: ({ row }: { row: { original: Exam } }) => {
|
|
||||||
return (
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<div
|
|
||||||
data-tip="Load exam"
|
|
||||||
className="cursor-pointer tooltip"
|
|
||||||
onClick={async () =>
|
|
||||||
await loadExam(row.original.module, row.original.id)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
|
||||||
</div>
|
|
||||||
{PERMISSIONS.examManagement.delete.includes(user.type) && (
|
|
||||||
<div
|
|
||||||
data-tip="Delete"
|
|
||||||
className="cursor-pointer tooltip"
|
|
||||||
onClick={() => deleteExam(row.original)}
|
|
||||||
>
|
|
||||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const table = useReactTable({
|
toast.error("Something went wrong, please try again later.");
|
||||||
data: parsedExams,
|
})
|
||||||
columns: defaultColumns,
|
.finally(reload);
|
||||||
getCoreRowModel: getCoreRowModel(),
|
};
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
const updateExam = async (exam: Exam, body: object) => {
|
||||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
if (!confirm(`Are you sure you want to update this ${capitalize(exam.module)} exam?`)) return;
|
||||||
<thead>
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
axios
|
||||||
<tr key={headerGroup.id}>
|
.patch(`/api/exam/${exam.module}/${exam.id}`, body)
|
||||||
{headerGroup.headers.map((header) => (
|
.then(() => toast.success(`Updated the "${exam.id}" exam`))
|
||||||
<th className="p-4 text-left" key={header.id}>
|
.catch((reason) => {
|
||||||
{header.isPlaceholder
|
if (reason.response.status === 404) {
|
||||||
? null
|
toast.error("Exam not found!");
|
||||||
: flexRender(
|
return;
|
||||||
header.column.columnDef.header,
|
}
|
||||||
header.getContext()
|
|
||||||
)}
|
if (reason.response.status === 403) {
|
||||||
</th>
|
toast.error("You do not have permission to update this exam!");
|
||||||
))}
|
return;
|
||||||
</tr>
|
}
|
||||||
))}
|
|
||||||
</thead>
|
toast.error("Something went wrong, please try again later.");
|
||||||
<tbody className="px-2">
|
})
|
||||||
{table.getRowModel().rows.map((row) => (
|
.finally(reload)
|
||||||
<tr
|
.finally(() => setSelectedExam(undefined));
|
||||||
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
|
};
|
||||||
key={row.id}
|
|
||||||
>
|
const deleteExam = async (exam: Exam) => {
|
||||||
{row.getVisibleCells().map((cell) => (
|
if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return;
|
||||||
<td className="px-4 py-2" key={cell.id}>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
axios
|
||||||
</td>
|
.delete(`/api/exam/${exam.module}/${exam.id}`)
|
||||||
))}
|
.then(() => toast.success(`Deleted the "${exam.id}" exam`))
|
||||||
</tr>
|
.catch((reason) => {
|
||||||
))}
|
if (reason.response.status === 404) {
|
||||||
</tbody>
|
toast.error("Exam not found!");
|
||||||
</table>
|
return;
|
||||||
);
|
}
|
||||||
|
|
||||||
|
if (reason.response.status === 403) {
|
||||||
|
toast.error("You do not have permission to delete this exam!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error("Something went wrong, please try again later.");
|
||||||
|
})
|
||||||
|
.finally(reload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTotalExercises = (exam: Exam) => {
|
||||||
|
if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") {
|
||||||
|
return countExercises(exam.parts.flatMap((x) => x.exercises));
|
||||||
|
}
|
||||||
|
|
||||||
|
return countExercises(exam.exercises);
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultColumns = [
|
||||||
|
columnHelper.accessor("id", {
|
||||||
|
header: "ID",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("module", {
|
||||||
|
header: "Module",
|
||||||
|
cell: (info) => <span className={CLASSES[info.getValue()]}>{capitalize(info.getValue())}</span>,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor((x) => getTotalExercises(x), {
|
||||||
|
header: "Exercises",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("minTimer", {
|
||||||
|
header: "Timer",
|
||||||
|
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", {
|
||||||
|
header: "Created At",
|
||||||
|
cell: (info) => {
|
||||||
|
const value = info.getValue();
|
||||||
|
if (value) {
|
||||||
|
return new Date(value).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("createdBy", {
|
||||||
|
header: "Created By",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
header: "",
|
||||||
|
id: "actions",
|
||||||
|
cell: ({row}: {row: {original: Exam}}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{(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"
|
||||||
|
className="cursor-pointer tooltip"
|
||||||
|
onClick={async () => await loadExam(row.original.module, row.original.id)}>
|
||||||
|
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
|
</button>
|
||||||
|
{PERMISSIONS.examManagement.delete.includes(user.type) && (
|
||||||
|
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteExam(row.original)}>
|
||||||
|
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: filteredRows,
|
||||||
|
columns: defaultColumns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
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">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
.getValue()
|
{info
|
||||||
.map((x) => users.find((y) => y.id === x)?.name)
|
.getValue()
|
||||||
.join(", "),
|
.slice(0, viewingAllParticipants === info.row.original.id ? undefined : 5)
|
||||||
|
.map((x) => users.find((y) => y.id === x)?.name)
|
||||||
|
.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,19 +46,32 @@ 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);
|
||||||
const [sorter, setSorter] = useState<string>();
|
const [sorter, setSorter] = useState<string>();
|
||||||
const [displayUsers, setDisplayUsers] = useState<User[]>([]);
|
const [displayUsers, setDisplayUsers] = useState<User[]>([]);
|
||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
|
||||||
const {users, reload} = useUsers();
|
const userHash = useMemo(
|
||||||
|
() => ({
|
||||||
|
type,
|
||||||
|
size: 16,
|
||||||
|
page,
|
||||||
|
}),
|
||||||
|
[type, page],
|
||||||
|
);
|
||||||
|
|
||||||
|
const {users, total, isLoading, reload} = 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 +92,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 +117,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 +174,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 +338,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 +421,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 +544,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 +612,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 +624,27 @@ export default function UserList({
|
|||||||
Download List
|
Download List
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-full flex gap-2 justify-between">
|
||||||
|
<Button
|
||||||
|
isLoading={isLoading}
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage((prev) => prev - 1)}>
|
||||||
|
Previous Page
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-4 w-fit">
|
||||||
|
<span className="opacity-80">
|
||||||
|
{page * 16 + 1} - {(page + 1) * 16 > total ? total : (page + 1) * 16} / {total}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
isLoading={isLoading}
|
||||||
|
className="w-[200px]"
|
||||||
|
disabled={page * 16 >= total}
|
||||||
|
onClick={() => setPage((prev) => prev + 1)}>
|
||||||
|
Next Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</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) => (
|
||||||
@@ -672,7 +657,7 @@ export default function UserList({
|
|||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="px-2">
|
<tbody className="px-2 w-full">
|
||||||
{table.getRowModel().rows.map((row) => (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
<tr 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) => (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user