Compare commits
349 Commits
settings-i
...
bugfix/mon
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c1c4489f8 | ||
|
|
044ec8d966 | ||
|
|
07c9074d15 | ||
|
|
71bac76c3a | ||
|
|
fb293dc98c | ||
|
|
3ce97b4dcd | ||
|
|
7bfd000213 | ||
|
|
2a10933206 | ||
|
|
33a46c227b | ||
|
|
5153c3d5f1 | ||
|
|
85c8f622ee | ||
|
|
b9c097d42c | ||
|
|
192132559b | ||
|
|
6d1e8a9788 | ||
|
|
1c61d50a5c | ||
|
|
9f0ba418e5 | ||
|
|
6fd2e64e04 | ||
|
|
2c01e6b460 | ||
|
|
6e0c4d4361 | ||
|
|
745eef981f | ||
|
|
7a33f42bcd | ||
|
|
02564c8426 | ||
|
|
eab6ab03b7 | ||
|
|
6f534662e1 | ||
|
|
fbc7abdabb | ||
|
|
392eac2ef9 | ||
|
|
b7349b5df8 | ||
|
|
298901a642 | ||
|
|
88eafafe12 | ||
|
|
31a01a3157 | ||
|
|
a5b3a7e94d | ||
|
|
49e8237e99 | ||
|
|
d5769c2cb9 | ||
|
|
e49a325074 | ||
|
|
e6528392a2 | ||
|
|
a073ca1cce | ||
|
|
620e4dd787 | ||
|
|
7e30ca5750 | ||
|
|
e3847baadb | ||
|
|
2e065eddcb | ||
|
|
4e91b2f1fb | ||
|
|
5b8631ab6a | ||
|
|
f9f29eabb3 | ||
|
|
898edb152f | ||
|
|
bf0d696b2f | ||
|
|
d91b1c14e7 | ||
|
|
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 | ||
|
|
720597e916 | ||
|
|
e74ded676e | ||
|
|
ee60eedd0d | ||
|
|
c37a1becbf | ||
|
|
b9cca483ec | ||
|
|
c758bdaf9e | ||
|
|
5ada588b16 | ||
|
|
eec1bb0c30 | ||
|
|
65f8368708 | ||
|
|
806e621c5b | ||
|
|
ce35b23714 | ||
|
|
2cd025b118 | ||
|
|
2e699d7e25 | ||
|
|
30da295c60 | ||
|
|
a82a399d52 | ||
|
|
505df31d6b | ||
|
|
a4d8ba72af | ||
|
|
2bfd0cb502 | ||
|
|
5ee071028c | ||
|
|
23b9452a3a | ||
|
|
0ce3a16d3a | ||
|
|
4315a7b17c | ||
|
|
247f192a0a | ||
|
|
9c944ae3d2 | ||
|
|
a390aa429d | ||
|
|
3367384791 | ||
|
|
158324a705 | ||
|
|
f9286d1793 | ||
|
|
2e376c37dd | ||
|
|
5bda9ed227 | ||
|
|
97b533bd3a | ||
|
|
75a45108a2 | ||
|
|
bfc0def20f | ||
|
|
9db33e6a51 | ||
|
|
ba5d926659 | ||
|
|
1cd4dfc397 | ||
|
|
bf5dd62b35 | ||
|
|
4e583d11b6 | ||
|
|
688505b4eb | ||
|
|
81b8ceb2b3 | ||
|
|
d93d36c392 | ||
|
|
3299acee36 | ||
|
|
abddead402 | ||
|
|
2d69fdac3c | ||
|
|
506ff2503e | ||
|
|
5d191730d2 | ||
|
|
346b131388 | ||
|
|
aba49e385f | ||
|
|
5789688eab | ||
|
|
f7da11bc69 | ||
|
|
10802f6bb5 | ||
|
|
37e356572b | ||
|
|
8669ef462d | ||
|
|
df1c0bad4d | ||
|
|
bcb1a0f914 | ||
|
|
bf1bdd935c | ||
|
|
edc9d4de2a | ||
|
|
229275aaee | ||
|
|
f0ff6ac691 | ||
|
|
878c7c2ef0 | ||
|
|
0a28c2bd41 | ||
|
|
38e48c90bb | ||
|
|
c6f35d7750 | ||
|
|
85f684dff5 | ||
|
|
d94a9bb88a | ||
|
|
1950d5f15d | ||
|
|
e84cc8ddd8 | ||
|
|
cf2fd06d39 | ||
|
|
b6015b6433 | ||
|
|
fea58a7b40 | ||
|
|
13284eab75 | ||
|
|
dd4e3a4694 | ||
|
|
eb55e65d91 | ||
|
|
cb75ba6056 | ||
|
|
859d9283a7 | ||
|
|
1a3437b333 | ||
|
|
bbbf17daa0 | ||
|
|
ae79aef132 | ||
|
|
c3e71b4389 | ||
|
|
2784117862 | ||
|
|
8162567e12 | ||
|
|
58300e32ff | ||
|
|
cb489bf0ca | ||
|
|
91bc91e725 | ||
|
|
ce086a8b22 | ||
|
|
6e71ee7cb0 | ||
|
|
21e58e3b9c | ||
|
|
b885dd46b5 | ||
|
|
0fc2df1070 | ||
|
|
cf91f1812d | ||
|
|
3289f27cd5 | ||
|
|
95c3f89911 | ||
|
|
48faee07f6 | ||
|
|
f0d7d7644b |
@@ -23,6 +23,8 @@ COPY . .
|
|||||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
ENV MONGODB_URI "mongodb+srv://user:JKpFBymv0WLv3STj@encoach.lz18a.mongodb.net/?retryWrites=true&w=majority&appName=EnCoach"
|
||||||
|
|
||||||
RUN yarn build
|
RUN yarn build
|
||||||
|
|
||||||
# If using npm comment out above and use below instead
|
# If using npm comment out above and use below instead
|
||||||
@@ -54,4 +56,4 @@ EXPOSE 3000
|
|||||||
ENV PORT 3000
|
ENV PORT 3000
|
||||||
ENV HOSTNAME localhost
|
ENV HOSTNAME localhost
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
CMD HOSTNAME="0.0.0.0" node server.js
|
||||||
17
components.json
Normal file
17
components.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "src/styles/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": false,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000";
|
const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000";
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: false,
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
|
|||||||
3346
package-lock.json
generated
3346
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@@ -12,33 +12,39 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@beam-australia/react-env": "^3.1.1",
|
"@beam-australia/react-env": "^3.1.1",
|
||||||
"@dnd-kit/core": "^6.1.0",
|
"@dnd-kit/core": "^6.1.0",
|
||||||
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
"@firebase/util": "^1.9.7",
|
"@firebase/util": "^1.9.7",
|
||||||
"@headlessui/react": "^1.7.13",
|
"@headlessui/react": "^2.1.2",
|
||||||
"@mdi/js": "^7.1.96",
|
"@mdi/js": "^7.1.96",
|
||||||
"@mdi/react": "^1.6.1",
|
"@mdi/react": "^1.6.1",
|
||||||
"@next/font": "13.1.6",
|
|
||||||
"@paypal/paypal-js": "^7.1.0",
|
"@paypal/paypal-js": "^7.1.0",
|
||||||
"@paypal/react-paypal-js": "^8.1.3",
|
"@paypal/react-paypal-js": "^8.1.3",
|
||||||
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
|
"@radix-ui/react-popover": "^1.1.1",
|
||||||
"@react-pdf/renderer": "^3.1.14",
|
"@react-pdf/renderer": "^3.1.14",
|
||||||
"@react-spring/web": "^9.7.4",
|
"@react-spring/web": "^9.7.4",
|
||||||
"@tanstack/react-table": "^8.10.1",
|
"@tanstack/react-table": "^8.10.1",
|
||||||
"@types/node": "18.13.0",
|
"@types/node": "18.13.0",
|
||||||
"@types/react": "18.0.27",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "18.0.10",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@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",
|
||||||
"clsx": "^1.2.1",
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"countries-list": "^3.0.1",
|
"countries-list": "^3.0.1",
|
||||||
"country-codes-list": "^1.6.11",
|
"country-codes-list": "^1.6.11",
|
||||||
"currency-symbol-map": "^5.1.0",
|
"currency-symbol-map": "^5.1.0",
|
||||||
"daisyui": "^3.1.5",
|
"daisyui": "^3.1.5",
|
||||||
"eslint": "8.33.0",
|
"eslint": "8.33.0",
|
||||||
"eslint-config-next": "13.1.6",
|
"eslint-config-next": "13.1.6",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"express-handlebars": "^7.1.2",
|
"express-handlebars": "^7.1.2",
|
||||||
"firebase": "9.19.1",
|
"firebase": "9.19.1",
|
||||||
"firebase-admin": "^11.10.1",
|
"firebase-admin": "^11.10.1",
|
||||||
|
"firebase-scrypt": "^2.2.0",
|
||||||
"formidable": "^3.5.0",
|
"formidable": "^3.5.0",
|
||||||
"formidable-serverless": "^1.1.1",
|
"formidable-serverless": "^1.1.1",
|
||||||
"framer-motion": "^9.0.2",
|
"framer-motion": "^9.0.2",
|
||||||
@@ -47,7 +53,8 @@
|
|||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"moment-timezone": "^0.5.44",
|
"moment-timezone": "^0.5.44",
|
||||||
"next": "13.1.6",
|
"mongodb": "^6.8.1",
|
||||||
|
"next": "^14.2.5",
|
||||||
"nodemailer": "^6.9.5",
|
"nodemailer": "^6.9.5",
|
||||||
"nodemailer-express-handlebars": "^6.1.0",
|
"nodemailer-express-handlebars": "^6.1.0",
|
||||||
"primeicons": "^6.0.1",
|
"primeicons": "^6.0.1",
|
||||||
@@ -62,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",
|
||||||
@@ -75,8 +82,10 @@
|
|||||||
"read-excel-file": "^5.7.1",
|
"read-excel-file": "^5.7.1",
|
||||||
"short-unique-id": "5.0.2",
|
"short-unique-id": "5.0.2",
|
||||||
"stripe": "^13.10.0",
|
"stripe": "^13.10.0",
|
||||||
"swr": "^2.1.3",
|
"swr": "^2.2.5",
|
||||||
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwind-scrollbar-hide": "^1.1.7",
|
"tailwind-scrollbar-hide": "^1.1.7",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "4.9.5",
|
"typescript": "4.9.5",
|
||||||
"use-file-picker": "^2.1.0",
|
"use-file-picker": "^2.1.0",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
@@ -84,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 |
@@ -1,56 +0,0 @@
|
|||||||
import {Dialog, Transition} from "@headlessui/react";
|
|
||||||
import {Fragment} from "react";
|
|
||||||
import Button from "./Low/Button";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: (next?: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BlankQuestionsModal({isOpen, onClose}: Props) {
|
|
||||||
return (
|
|
||||||
<Transition show={isOpen} as={Fragment}>
|
|
||||||
<Dialog onClose={() => onClose(false)} className="relative z-50">
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0">
|
|
||||||
<div className="fixed inset-0 bg-black/30" />
|
|
||||||
</Transition.Child>
|
|
||||||
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95">
|
|
||||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
|
||||||
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
|
|
||||||
<Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title>
|
|
||||||
<span>
|
|
||||||
Please note that you are finishing the current module and once you proceed to the next module, you will no longer be
|
|
||||||
able to change the answers in the current one, including your unanswered questions. <br />
|
|
||||||
<br />
|
|
||||||
Are you sure you want to continue without completing those questions?
|
|
||||||
</span>
|
|
||||||
<div className="w-full flex justify-between mt-8">
|
|
||||||
<Button color="purple" onClick={() => onClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
|
|
||||||
Go Back
|
|
||||||
</Button>
|
|
||||||
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full">
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</div>
|
|
||||||
</Transition.Child>
|
|
||||||
</Dialog>
|
|
||||||
</Transition>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -17,18 +17,22 @@ import moment from "moment";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
mutateUser: KeyedMutator<User>;
|
mutateUser: (user: User) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
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>();
|
||||||
@@ -38,7 +42,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.patch("/api/users/update", {
|
.patch<{user: User}>("/api/users/update", {
|
||||||
demographicInformation: {
|
demographicInformation: {
|
||||||
country,
|
country,
|
||||||
phone: `+${countryCodes.findOne("countryCode" as any, country!).countryCallingCode}${phone}`,
|
phone: `+${countryCodes.findOne("countryCode" as any, country!).countryCallingCode}${phone}`,
|
||||||
@@ -50,7 +54,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
|||||||
},
|
},
|
||||||
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
|
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
|
||||||
})
|
})
|
||||||
.then((response) => mutateUser((response.data as {user: User}).user))
|
.then((response) => mutateUser(response.data.user))
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"});
|
toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"});
|
||||||
})
|
})
|
||||||
@@ -85,7 +89,15 @@ 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 +118,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>
|
||||||
|
|||||||
@@ -1,187 +0,0 @@
|
|||||||
import {FillBlanksExercise} from "@/interfaces/exam";
|
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import {Fragment, useEffect, useState} from "react";
|
|
||||||
import reactStringReplace from "react-string-replace";
|
|
||||||
import {CommonProps} from ".";
|
|
||||||
import Button from "../Low/Button";
|
|
||||||
|
|
||||||
interface WordsDrawerProps {
|
|
||||||
words: {word: string; isDisabled: boolean}[];
|
|
||||||
isOpen: boolean;
|
|
||||||
blankId?: string;
|
|
||||||
previouslySelectedWord?: string;
|
|
||||||
onCancel: () => void;
|
|
||||||
onAnswer: (answer: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel, onAnswer}: WordsDrawerProps) {
|
|
||||||
const [selectedWord, setSelectedWord] = useState<string | undefined>(previouslySelectedWord);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"w-full h-full absolute top-0 left-0 bg-gradient-to-t from-mti-black to-transparent z-10",
|
|
||||||
isOpen ? "visible opacity-10" : "invisible opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"absolute w-full bg-white px-7 py-8 bottom-0 left-0 shadow-2xl rounded-2xl z-20 flex flex-col gap-8 transition-opacity duration-300 ease-in-out",
|
|
||||||
isOpen ? "visible opacity-100" : "invisible opacity-0",
|
|
||||||
)}>
|
|
||||||
<div className="w-full flex gap-2">
|
|
||||||
<div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-purple-light">{blankId}</div>
|
|
||||||
<span> Choose the correct word:</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-6 gap-6" key="word-array">
|
|
||||||
{words.map(({word, isDisabled}) => (
|
|
||||||
<button
|
|
||||||
key={`${word}_${blankId}`}
|
|
||||||
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
|
|
||||||
className={clsx(
|
|
||||||
"rounded-full py-3 text-center transition duration-300 ease-in-out",
|
|
||||||
selectedWord === word ? "text-white bg-mti-purple-light" : "bg-mti-purple-ultralight",
|
|
||||||
!isDisabled && "hover:text-white hover:bg-mti-purple",
|
|
||||||
"disabled:cursor-not-allowed disabled:text-mti-gray-dim",
|
|
||||||
)}
|
|
||||||
disabled={isDisabled}>
|
|
||||||
{word}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between w-full">
|
|
||||||
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
|
|
||||||
Confirm
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FillBlanks({
|
|
||||||
id,
|
|
||||||
allowRepetition,
|
|
||||||
type,
|
|
||||||
prompt,
|
|
||||||
solutions,
|
|
||||||
text,
|
|
||||||
words,
|
|
||||||
userSolutions,
|
|
||||||
onNext,
|
|
||||||
onBack,
|
|
||||||
}: FillBlanksExercise & CommonProps) {
|
|
||||||
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
|
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [hasExamEnded]);
|
|
||||||
|
|
||||||
const calculateScore = () => {
|
|
||||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
|
||||||
const correct = answers.filter((x) => {
|
|
||||||
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution.toLowerCase();
|
|
||||||
if (!solution) return false;
|
|
||||||
|
|
||||||
const option = words.find((w) =>
|
|
||||||
typeof w === "string" ? w.toLowerCase() === x.solution.toLowerCase() : w.letter.toLowerCase() === x.solution.toLowerCase(),
|
|
||||||
);
|
|
||||||
if (!option) return false;
|
|
||||||
|
|
||||||
return solution === (typeof option === "string" ? option.toLowerCase() : option.word.toLowerCase());
|
|
||||||
}).length;
|
|
||||||
const missing = total - answers.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
|
||||||
|
|
||||||
return {total, correct, missing};
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderLines = (line: string) => {
|
|
||||||
return (
|
|
||||||
<div className="text-base leading-5">
|
|
||||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
|
||||||
const id = match.replaceAll(/[\{\}]/g, "");
|
|
||||||
const userSolution = answers.find((x) => x.id === id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
className={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 && "px-5 py-2 text-center text-mti-purple-dark bg-mti-purple-ultralight",
|
|
||||||
)}
|
|
||||||
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), {id, solution: e.target.value}])}
|
|
||||||
value={userSolution?.solution}></input>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
|
||||||
<span className="text-sm w-full leading-6">
|
|
||||||
{prompt.split("\\n").map((line, index) => (
|
|
||||||
<Fragment key={index}>
|
|
||||||
{line}
|
|
||||||
<br />
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
|
||||||
{text.split("\\n").map((line, index) => (
|
|
||||||
<p key={index}>
|
|
||||||
{renderLines(line)}
|
|
||||||
<br />
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
<div 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) => {
|
|
||||||
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 : v.letter).toLowerCase()) &&
|
|
||||||
"bg-mti-purple-dark text-white",
|
|
||||||
)}
|
|
||||||
key={text}>
|
|
||||||
{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})}
|
|
||||||
className="max-w-[200px] w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
|
||||||
className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
84
src/components/Exercises/FillBlanks/MCDropdown.tsx
Normal file
84
src/components/Exercises/FillBlanks/MCDropdown.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import React, { useRef, useEffect, useState } from 'react';
|
||||||
|
import { animated, useSpring } from '@react-spring/web';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
interface MCDropdownProps {
|
||||||
|
id: string;
|
||||||
|
options: { [key: string]: string };
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
selectedValue?: string;
|
||||||
|
className?: string;
|
||||||
|
width: number;
|
||||||
|
isOpen: boolean;
|
||||||
|
onToggle: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MCDropdown: React.FC<MCDropdownProps> = ({
|
||||||
|
id,
|
||||||
|
options,
|
||||||
|
onSelect,
|
||||||
|
selectedValue,
|
||||||
|
className = "relative",
|
||||||
|
width,
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
}) => {
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [contentHeight, setContentHeight] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (contentRef.current) {
|
||||||
|
setContentHeight(contentRef.current.scrollHeight);
|
||||||
|
}
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
const springProps = useSpring({
|
||||||
|
height: isOpen ? contentHeight : 0,
|
||||||
|
opacity: isOpen ? 1 : 0,
|
||||||
|
config: { tension: 300, friction: 30 }
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${className} inline-block`} style={{ width: `${width}px` }}>
|
||||||
|
<button
|
||||||
|
onClick={() => onToggle(id)}
|
||||||
|
className={
|
||||||
|
clsx("rounded-full hover:text-white transition duration-300 ease-in-out px-5 py-2 text-center w-full flex items-center justify-between",
|
||||||
|
selectedValue ? "bg-mti-purple text-white" : "bg-mti-purple-ultralight text-mti-purple-light"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate p-1">{selectedValue || 'Select an option'}</span>
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 transform transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<animated.div
|
||||||
|
style={{ ...springProps, width: `${width}px` }}
|
||||||
|
className="absolute z-10 mt-1 overflow-hidden bg-white rounded-md shadow-lg"
|
||||||
|
>
|
||||||
|
<div ref={contentRef}>
|
||||||
|
{Object.entries(options).sort((a, b) => a[0].localeCompare(b[0])).map(([key, value]) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(value);
|
||||||
|
onToggle(id);
|
||||||
|
}}
|
||||||
|
className="p-4 hover:bg-mti-purple-ultralight cursor-pointer whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<span>{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</animated.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MCDropdown;
|
||||||
64
src/components/Exercises/FillBlanks/WordsDrawer.tsx
Normal file
64
src/components/Exercises/FillBlanks/WordsDrawer.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface WordsDrawerProps {
|
||||||
|
words: {word: string; isDisabled: boolean}[];
|
||||||
|
isOpen: boolean;
|
||||||
|
blankId?: string;
|
||||||
|
previouslySelectedWord?: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onAnswer: (answer: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const WordsDrawer: React.FC<WordsDrawerProps> = ({words, isOpen, blankId, previouslySelectedWord, onCancel, onAnswer}) => {
|
||||||
|
const [selectedWord, setSelectedWord] = useState<string | undefined>(previouslySelectedWord);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"w-full h-full absolute top-0 left-0 bg-gradient-to-t from-mti-black to-transparent z-10",
|
||||||
|
isOpen ? "visible opacity-10" : "invisible opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"absolute w-full bg-white px-7 py-8 bottom-0 left-0 shadow-2xl rounded-2xl z-20 flex flex-col gap-8 transition-opacity duration-300 ease-in-out",
|
||||||
|
isOpen ? "visible opacity-100" : "invisible opacity-0",
|
||||||
|
)}>
|
||||||
|
<div className="w-full flex gap-2">
|
||||||
|
<div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-purple-light">{blankId}</div>
|
||||||
|
<span> Choose the correct word:</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-6 gap-6" key="word-array">
|
||||||
|
{words.map(({word, isDisabled}) => (
|
||||||
|
<button
|
||||||
|
key={`${word}_${blankId}`}
|
||||||
|
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
|
||||||
|
className={clsx(
|
||||||
|
"rounded-full py-3 text-center transition duration-300 ease-in-out",
|
||||||
|
selectedWord === word ? "text-white bg-mti-purple-light" : "bg-mti-purple-ultralight",
|
||||||
|
!isDisabled && "hover:text-white hover:bg-mti-purple",
|
||||||
|
"disabled:cursor-not-allowed disabled:text-mti-gray-dim",
|
||||||
|
)}
|
||||||
|
disabled={isDisabled}>
|
||||||
|
{word}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between w-full">
|
||||||
|
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WordsDrawer;
|
||||||
239
src/components/Exercises/FillBlanks/index.tsx
Normal file
239
src/components/Exercises/FillBlanks/index.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import reactStringReplace from "react-string-replace";
|
||||||
|
import { CommonProps } from "..";
|
||||||
|
import Button from "../../Low/Button";
|
||||||
|
import { v4 } from "uuid";
|
||||||
|
import MCDropdown from "./MCDropdown";
|
||||||
|
|
||||||
|
const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
prompt,
|
||||||
|
solutions,
|
||||||
|
text,
|
||||||
|
words,
|
||||||
|
userSolutions,
|
||||||
|
variant,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
}) => {
|
||||||
|
const { shuffles, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state);
|
||||||
|
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
|
||||||
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
|
||||||
|
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const excludeWordMCType = (x: any) => {
|
||||||
|
return typeof x === "string" ? x : (x as { letter: string; word: string });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type });
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
|
let correctWords: any;
|
||||||
|
if (exam && (exam.module === "level" || exam.module === "reading") && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") {
|
||||||
|
correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setOpenDropdownId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const calculateScore = () => {
|
||||||
|
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||||
|
const correct = answers!.filter((x) => {
|
||||||
|
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
|
||||||
|
if (!solution) return false;
|
||||||
|
const option = correctWords!.find((w: any) => {
|
||||||
|
if (typeof w === "string") {
|
||||||
|
return w.toLowerCase() === x.solution.toLowerCase();
|
||||||
|
} else if ("letter" in w) {
|
||||||
|
return w.letter.toLowerCase() === x.solution.toLowerCase();
|
||||||
|
} else {
|
||||||
|
return w.id.toString() === x.id.toString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!option) return false;
|
||||||
|
|
||||||
|
if (typeof option === "string") {
|
||||||
|
return solution.toLowerCase() === option.toLowerCase();
|
||||||
|
} else if ("letter" in option) {
|
||||||
|
return solution.toLowerCase() === option.word.toLowerCase();
|
||||||
|
} else if ("options" in option) {
|
||||||
|
return option.options[solution as keyof typeof option.options] == x.solution;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}).length;
|
||||||
|
const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||||
|
return { total, correct, missing };
|
||||||
|
};
|
||||||
|
|
||||||
|
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const renderLines = useCallback(
|
||||||
|
(line: string) => {
|
||||||
|
return (
|
||||||
|
<div className="text-xl leading-5" key={v4()} ref={dropdownRef}>
|
||||||
|
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||||
|
const id = match.replaceAll(/[\{\}]/g, "");
|
||||||
|
const userSolution = answers.find((x) => x.id === id);
|
||||||
|
const styles = clsx(
|
||||||
|
"rounded-full hover:text-white transition duration-300 ease-in-out my-1 px-5 py-2 text-center w-fit",
|
||||||
|
!userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
|
||||||
|
userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight",
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentSelection = words.find((x) => {
|
||||||
|
if (typeof x !== "string" && "id" in x) {
|
||||||
|
return (x as FillBlanksMCOption).id.toString() == id.toString();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}) as FillBlanksMCOption;
|
||||||
|
|
||||||
|
return variant === "mc" ? (
|
||||||
|
<MCDropdown
|
||||||
|
id={id}
|
||||||
|
options={currentSelection.options}
|
||||||
|
onSelect={(value) => onSelection(id, value)}
|
||||||
|
selectedValue={userSolution?.solution}
|
||||||
|
className="inline-block py-2 px-1"
|
||||||
|
width={220}
|
||||||
|
isOpen={openDropdownId === id}
|
||||||
|
onToggle={()=> setOpenDropdownId(prevId => prevId === id ? null : id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
className={styles}
|
||||||
|
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])}
|
||||||
|
value={userSolution?.solution}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div >
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[variant, words, answers, openDropdownId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const memoizedLines = useMemo(() => {
|
||||||
|
return text.split("\\n").map((line, index) => (
|
||||||
|
<p key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}>
|
||||||
|
{renderLines(line)}
|
||||||
|
<br />
|
||||||
|
</p>
|
||||||
|
));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [text, variant, renderLines]);
|
||||||
|
|
||||||
|
const onSelection = (questionID: string, value: string) => {
|
||||||
|
setAnswers((prev) => [...prev.filter((x) => x.id !== questionID), { id: questionID, solution: value }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (variant === "mc") {
|
||||||
|
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [answers]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps })}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
||||||
|
Previous Page
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => {
|
||||||
|
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
||||||
|
}}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
|
{variant !== "mc" && (
|
||||||
|
<span className="text-sm w-full leading-6">
|
||||||
|
{prompt.split("\\n").map((line, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">{memoizedLines}</span>
|
||||||
|
{variant !== "mc" && (
|
||||||
|
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-4">
|
||||||
|
<span className="font-medium text-mti-purple-dark">Options</span>
|
||||||
|
<div className="flex gap-4 flex-wrap">
|
||||||
|
{words.map((v) => {
|
||||||
|
v = excludeWordMCType(v);
|
||||||
|
const text = typeof v === "string" ? v : `${v.letter} - ${v.word}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300",
|
||||||
|
!!answers.find(
|
||||||
|
(x) =>
|
||||||
|
x.solution.toLowerCase() ===
|
||||||
|
(typeof v === "string" ? v : "letter" in v ? v.letter : "").toLowerCase(),
|
||||||
|
) && "bg-mti-purple-dark text-white",
|
||||||
|
)}
|
||||||
|
key={v4()}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps })}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
||||||
|
Previous Page
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => {
|
||||||
|
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
||||||
|
}}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FillBlanks;
|
||||||
@@ -152,6 +152,16 @@ export default function InteractiveSpeaking({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||||
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
|
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col h-full w-full gap-9">
|
<div className="flex flex-col h-full w-full gap-9">
|
||||||
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -293,5 +303,6 @@ export default function InteractiveSpeaking({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
|
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [answers, setAnswers]);
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
if (event.over && event.over.id.toString().startsWith("droppable")) {
|
if (event.over && event.over.id.toString().startsWith("droppable")) {
|
||||||
const optionID = event.active.id.toString().replace("draggable_option_", "");
|
const optionID = event.active.id.toString().replace("draggable_option_", "");
|
||||||
@@ -93,7 +100,24 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
}, [hasExamEnded]);
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
@@ -143,6 +167,6 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +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";
|
||||||
|
|
||||||
function Question({
|
function Question({
|
||||||
id,
|
id,
|
||||||
@@ -14,20 +15,24 @@ function Question({
|
|||||||
options,
|
options,
|
||||||
userSolution,
|
userSolution,
|
||||||
onSelectOption,
|
onSelectOption,
|
||||||
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
}: MultipleChoiceQuestion & {
|
||||||
|
userSolution: string | undefined;
|
||||||
|
onSelectOption?: (option: string) => void;
|
||||||
|
showSolution?: boolean;
|
||||||
|
}) {
|
||||||
const renderPrompt = (prompt: string) => {
|
const renderPrompt = (prompt: string) => {
|
||||||
return reactStringReplace(prompt, /((<u>)[\w\s']+(<\/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;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-10">
|
<div className="flex flex-col gap-8">
|
||||||
{isNaN(Number(id)) ? (
|
{isNaN(Number(id)) ? (
|
||||||
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
<span className="text-lg">{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="">
|
<span className="text-lg">
|
||||||
<>
|
<>
|
||||||
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
|
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
|
||||||
</>
|
</>
|
||||||
@@ -37,24 +42,26 @@ function Question({
|
|||||||
{variant === "image" &&
|
{variant === "image" &&
|
||||||
options.map((option) => (
|
options.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={option.id.toString()}
|
key={v4()}
|
||||||
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative",
|
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative select-none",
|
||||||
userSolution === option.id.toString() && "border-mti-purple-light",
|
userSolution === option.id.toString() && "border-mti-purple-light",
|
||||||
)}>
|
)}>
|
||||||
<span className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>{option.id.toString()}</span>
|
<span key={v4()} className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>
|
||||||
|
{option.id.toString()}
|
||||||
|
</span>
|
||||||
<img src={option.src!} alt={`Option ${option.id.toString()}`} />
|
<img src={option.src!} alt={`Option ${option.id.toString()}`} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{variant === "text" &&
|
{variant === "text" &&
|
||||||
options.map((option) => (
|
options.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={option.id.toString()}
|
key={v4()}
|
||||||
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm",
|
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none",
|
||||||
userSolution === option.id.toString() && "border-mti-purple-light",
|
userSolution === option.id.toString() && "!bg-mti-purple-light !text-white",
|
||||||
)}>
|
)}>
|
||||||
<span className="font-semibold">{option.id.toString()}.</span>
|
<span className="font-semibold">{option.id.toString()}.</span>
|
||||||
<span>{option.text}</span>
|
<span>{option.text}</span>
|
||||||
@@ -68,79 +75,147 @@ 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 {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
const {questionIndex, exerciseIndex, exam, shuffles, hasExamEnded, partIndex, setQuestionIndex, setCurrentSolution} = useExamStore(
|
||||||
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
|
(state) => state,
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
);
|
||||||
|
|
||||||
|
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
|
||||||
|
|
||||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setUserSolutions([...storeUserSolutions.filter((x) => x.exercise !== id), {exercise: id, solutions: answers, score: calculateScore(), type}]);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [answers]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [hasExamEnded]);
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
const onSelectOption = (option: string) => {
|
const onSelectOption = (option: string, question: MultipleChoiceQuestion) => {
|
||||||
const question = questions[questionIndex];
|
|
||||||
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
|
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [answers, setAnswers]);
|
||||||
|
|
||||||
|
const getShuffledSolution = (originalSolution: string, questionShuffleMap: ShuffleMap) => {
|
||||||
|
for (const [newPosition, originalPosition] of Object.entries(questionShuffleMap.map)) {
|
||||||
|
if (originalPosition === originalSolution) {
|
||||||
|
return newPosition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return originalSolution;
|
||||||
|
};
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = questions.length;
|
const total = questions.length;
|
||||||
const correct = answers.filter(
|
const correct = answers.filter((x) => {
|
||||||
(x) => questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
|
const matchingQuestion = questions.find((y) => {
|
||||||
).length;
|
return y.id.toString() === x.question.toString();
|
||||||
const missing = total - answers.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length;
|
});
|
||||||
|
|
||||||
|
let isSolutionCorrect;
|
||||||
|
if (!shuffleMaps) {
|
||||||
|
isSolutionCorrect = matchingQuestion?.solution === x.option;
|
||||||
|
} else {
|
||||||
|
const shuffleMap = shuffleMaps.find((map) => map.questionID == x.question);
|
||||||
|
if (shuffleMap) {
|
||||||
|
isSolutionCorrect = getShuffledSolution(x.option, shuffleMap) == matchingQuestion?.solution;
|
||||||
|
} else {
|
||||||
|
isSolutionCorrect = matchingQuestion?.solution === x.option;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return isSolutionCorrect || false;
|
||||||
|
}).length;
|
||||||
|
const missing = total - answers!.filter((x) => questions.find((y) => x.question.toString() === y.id.toString())).length;
|
||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
if (questionIndex === questions.length - 1) {
|
if (questionIndex + 1 >= questions.length - 1) {
|
||||||
onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
onNext({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
|
||||||
} else {
|
} else {
|
||||||
setQuestionIndex(questionIndex + 1);
|
setQuestionIndex(questionIndex + 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
};
|
};
|
||||||
|
|
||||||
const back = () => {
|
const back = () => {
|
||||||
if (questionIndex === 0) {
|
if (questionIndex === 0) {
|
||||||
onBack({exercise: id, solutions: answers, score: calculateScore(), type});
|
onBack({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
|
||||||
} else {
|
} else {
|
||||||
setQuestionIndex(questionIndex - 1);
|
if (exam?.module === "level" && typeof exam.parts[0].intro !== "undefined" && questionIndex === 0) return;
|
||||||
|
setQuestionIndex(questionIndex - 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-2 mt-4 h-fit 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">{prompt}</span>
|
<Button
|
||||||
{questionIndex < questions.length && (
|
color="purple"
|
||||||
<Question
|
variant="outline"
|
||||||
{...questions[questionIndex]}
|
onClick={back}
|
||||||
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
|
className="max-w-[200px] w-full"
|
||||||
onSelectOption={onSelectOption}
|
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
|
||||||
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full">
|
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
Next
|
{exam &&
|
||||||
|
exam.module === "level" &&
|
||||||
|
partIndex === exam.parts.length - 1 &&
|
||||||
|
exerciseIndex === exam.parts[partIndex].exercises.length - 1 &&
|
||||||
|
questionIndex + 1 >= questions.length - 1
|
||||||
|
? "Submit"
|
||||||
|
: "Next"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
|
<div className="flex flex-col gap-4 mt-4 mb-20">
|
||||||
|
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
|
{/*<span className="text-xl font-semibold mb-2">{"Select the appropriate option."}</span>*/}
|
||||||
|
{questionIndex < questions.length && (
|
||||||
|
<Question
|
||||||
|
{...questions[questionIndex]}
|
||||||
|
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
|
||||||
|
onSelectOption={(option) => onSelectOption(option, questions[questionIndex])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{questionIndex + 1 < questions.length && (
|
||||||
|
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
|
<Question
|
||||||
|
{...questions[questionIndex + 1]}
|
||||||
|
userSolution={answers.find((x) => questions[questionIndex + 1].id === x.question)?.option}
|
||||||
|
onSelectOption={(option) => onSelectOption(option, questions[questionIndex + 1])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={back}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
|
{exam &&
|
||||||
|
exam.module === "level" &&
|
||||||
|
partIndex === exam.parts.length - 1 &&
|
||||||
|
exerciseIndex === exam.parts[partIndex].exercises.length - 1 &&
|
||||||
|
questionIndex + 1 >= questions.length - 1
|
||||||
|
? "Submit"
|
||||||
|
: "Next"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,16 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||||
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col h-full w-full gap-9">
|
<div className="flex flex-col h-full w-full gap-9">
|
||||||
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
|
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
|
||||||
<div className="flex flex-col items-center justify-center gap-4 w-full h-full">
|
<div className="flex flex-col items-center justify-center gap-4 w-full h-full">
|
||||||
@@ -134,7 +144,9 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
<div className="flex flex-col gap-0">
|
<div className="flex flex-col gap-0">
|
||||||
<span className="font-semibold">{title}</span>
|
<span className="font-semibold">{title}</span>
|
||||||
{prompts.length > 0 && (
|
{prompts.length > 0 && (
|
||||||
<span className="font-semibold">You should talk for at least 1 minute and 30 seconds for your answer to be valid.</span>
|
<span className="font-semibold">
|
||||||
|
You should talk for at least 1 minute and 30 seconds for your answer to be valid.
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!video_url && (
|
{!video_url && (
|
||||||
@@ -300,5 +312,6 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
const [answers, setAnswers] = useState<{id: string; solution: "true" | "false" | "not_given"}[]>(userSolutions);
|
const [answers, setAnswers] = useState<{id: string; solution: "true" | "false" | "not_given"}[]>(userSolutions);
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
@@ -28,6 +29,11 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [answers, setAnswers]);
|
||||||
|
|
||||||
const toggleAnswer = (solution: "true" | "false" | "not_given", questionId: string) => {
|
const toggleAnswer = (solution: "true" | "false" | "not_given", questionId: string) => {
|
||||||
const answer = answers.find((x) => x.id === questionId);
|
const answer = answers.find((x) => x.id === questionId);
|
||||||
if (answer && answer.solution === solution) {
|
if (answer && answer.solution === solution) {
|
||||||
@@ -39,7 +45,24 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
@@ -116,6 +139,6 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ function Blank({
|
|||||||
export default function WriteBlanks({id, prompt, type, maxWords, solutions, userSolutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
|
export default function WriteBlanks({id, prompt, type, maxWords, solutions, userSolutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
|
||||||
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
|
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const {hasExamEnded, setCurrentSolution} = useExamStore((state) => state);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
@@ -70,6 +70,11 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
|
|||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [answers, setAnswers]);
|
||||||
|
|
||||||
const renderLines = (line: string) => {
|
const renderLines = (line: string) => {
|
||||||
return (
|
return (
|
||||||
<span className="text-base leading-5">
|
<span className="text-base leading-5">
|
||||||
@@ -87,7 +92,24 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
@@ -123,6 +145,6 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,34 @@ export default function Writing({
|
|||||||
}, [inputText, wordCounter]);
|
}, [inputText, wordCounter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
disabled={!isSubmitEnabled}
|
||||||
|
onClick={() =>
|
||||||
|
onNext({
|
||||||
|
exercise: id,
|
||||||
|
solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}],
|
||||||
|
score: {correct: 100, total: 100, missing: 0},
|
||||||
|
type,
|
||||||
|
module: "writing",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{attachment && (
|
{attachment && (
|
||||||
<Transition show={isModalOpen} as={Fragment}>
|
<Transition show={isModalOpen} as={Fragment}>
|
||||||
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
||||||
@@ -170,6 +197,6 @@ export default function Writing({
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {FillBlanksExercise} from "@/interfaces/exam";
|
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
exercise: FillBlanksExercise;
|
exercise: FillBlanksExercise;
|
||||||
@@ -9,10 +10,15 @@ interface Props {
|
|||||||
|
|
||||||
const FillBlanksEdit = (props: Props) => {
|
const FillBlanksEdit = (props: Props) => {
|
||||||
const { exercise, updateExercise } = props;
|
const { exercise, updateExercise } = props;
|
||||||
|
|
||||||
|
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
|
||||||
|
return Array.isArray(words) && words.every((word) => word && typeof word === "object" && "id" in word && "options" in word);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type={exercise?.variant && exercise.variant === "mc" ? "textarea" : "text"}
|
||||||
label="Prompt"
|
label="Prompt"
|
||||||
name="prompt"
|
name="prompt"
|
||||||
required
|
required
|
||||||
@@ -24,18 +30,18 @@ const FillBlanksEdit = (props: Props) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type={exercise?.variant && exercise.variant === "mc" ? "textarea" : "text"}
|
||||||
label="Text"
|
label="Text"
|
||||||
name="text"
|
name="text"
|
||||||
required
|
required
|
||||||
value={exercise.text}
|
value={exercise.text}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
updateExercise({
|
updateExercise({
|
||||||
text: value,
|
text: exercise?.variant && exercise.variant === "mc" ? value : value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<h1>Solutions</h1>
|
<h1 className="mt-4">Solutions</h1>
|
||||||
<div className="w-full flex flex-wrap -mx-2">
|
<div className="w-full flex flex-wrap -mx-2">
|
||||||
{exercise.solutions.map((solution, index) => (
|
{exercise.solutions.map((solution, index) => (
|
||||||
<div key={solution.id} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
<div key={solution.id} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
||||||
@@ -54,16 +60,56 @@ const FillBlanksEdit = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<h1>Words</h1>
|
<h1 className="mt-4">Words</h1>
|
||||||
<div className="w-full flex flex-wrap -mx-2">
|
<div className={clsx(exercise?.variant && exercise.variant === "mc" ? "w-full flex flex-row" : "w-full flex flex-wrap -mx-2")}>
|
||||||
{exercise.words.map((word, index) => (
|
{exercise?.variant && exercise.variant === "mc" && typeCheckWordsMC(exercise.words) ?
|
||||||
|
(
|
||||||
|
<div className="flex flex-col w-full">
|
||||||
|
{exercise.words.flatMap((mcOptions, wordIndex) =>
|
||||||
|
<>
|
||||||
|
<label className="font-semibold">{`Word ${wordIndex + 1}`}</label>
|
||||||
|
<div className="flex flex-row">
|
||||||
|
{Object.entries(mcOptions.options).map(([key, value], optionIndex) => (
|
||||||
|
<div key={`${wordIndex}-${optionIndex}-${key}`} className="flex sm:w-1/2 lg:w-1/4 px-2 mb-4">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label={`Option ${key}`}
|
||||||
|
name="word"
|
||||||
|
required
|
||||||
|
value={value}
|
||||||
|
onChange={(newValue) =>
|
||||||
|
updateExercise({
|
||||||
|
words: exercise.words.map((word, idx) =>
|
||||||
|
idx === wordIndex
|
||||||
|
? {
|
||||||
|
...(word as FillBlanksMCOption),
|
||||||
|
options: {
|
||||||
|
...(word as FillBlanksMCOption).options,
|
||||||
|
[key]: newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: word
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
:
|
||||||
|
(
|
||||||
|
exercise.words.map((word, index) => (
|
||||||
<div key={index} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
<div key={index} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
label={`Word ${index + 1}`}
|
label={`Word ${index + 1}`}
|
||||||
name="word"
|
name="word"
|
||||||
required
|
required
|
||||||
value={typeof word === "string" ? word : word.word}
|
value={typeof word === "string" ? word : ("word" in word ? word.word : "")}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
updateExercise({
|
updateExercise({
|
||||||
words: exercise.words.map((sol, idx) =>
|
words: exercise.words.map((sol, idx) =>
|
||||||
@@ -73,7 +119,9 @@ const FillBlanksEdit = (props: Props) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,14 +11,15 @@ interface Props {
|
|||||||
className?: string;
|
className?: string;
|
||||||
navDisabled?: boolean;
|
navDisabled?: boolean;
|
||||||
focusMode?: boolean;
|
focusMode?: boolean;
|
||||||
|
bgColor?: string;
|
||||||
onFocusLayerMouseEnter?: () => void;
|
onFocusLayerMouseEnter?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layout({user, children, className, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
export default function Layout({user, children, className, bgColor="bg-white", navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative">
|
<main className={clsx("w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative")}>
|
||||||
<Navbar
|
<Navbar
|
||||||
path={router.pathname}
|
path={router.pathname}
|
||||||
user={user}
|
user={user}
|
||||||
@@ -37,7 +38,8 @@ export default function Layout({user, children, className, navDisabled = false,
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-full min-h-full h-fit md:mr-8 bg-white shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2",
|
`w-full min-h-full md:mr-8 ${bgColor} shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2`,
|
||||||
|
bgColor !== "bg-white" ? "justify-center" : "h-fit",
|
||||||
className,
|
className,
|
||||||
)}>
|
)}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {
|
import {Ticket, TicketStatus, TicketStatusLabel, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
|
||||||
Ticket,
|
|
||||||
TicketStatus,
|
|
||||||
TicketStatusLabel,
|
|
||||||
TicketType,
|
|
||||||
TicketTypeLabel,
|
|
||||||
} from "@/interfaces/ticket";
|
|
||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -31,16 +25,13 @@ export default function TicketDisplay({ user, ticket, onClose }: Props) {
|
|||||||
const [reporter] = useState(ticket.reporter);
|
const [reporter] = useState(ticket.reporter);
|
||||||
const [reportedFrom] = useState(ticket.reportedFrom);
|
const [reportedFrom] = useState(ticket.reportedFrom);
|
||||||
const [status, setStatus] = useState(ticket.status);
|
const [status, setStatus] = useState(ticket.status);
|
||||||
const [assignedTo, setAssignedTo] = useState<string | null>(
|
const [assignedTo, setAssignedTo] = useState<string | null>(ticket.assignedTo || null);
|
||||||
ticket.assignedTo || null,
|
|
||||||
);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const {users} = useUsers();
|
const {users} = useUsers();
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
if (!type)
|
if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"});
|
||||||
return toast.error("Please choose a type!", { toastId: "missing-type" });
|
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
@@ -87,37 +78,23 @@ export default function TicketDisplay({ user, ticket, onClose }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="flex flex-col gap-4 pt-8">
|
<form className="flex flex-col gap-4 pt-8">
|
||||||
<Input
|
<Input label="Subject" type="text" name="subject" placeholder="Subject..." value={subject} onChange={(e) => null} disabled />
|
||||||
label="Subject"
|
|
||||||
type="text"
|
|
||||||
name="subject"
|
|
||||||
placeholder="Subject..."
|
|
||||||
value={subject}
|
|
||||||
onChange={(e) => null}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex w-full flex-col gap-3">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">
|
<label className="text-mti-gray-dim text-base font-normal">Status</label>
|
||||||
Status
|
|
||||||
</label>
|
|
||||||
<Select
|
<Select
|
||||||
options={Object.keys(TicketStatusLabel).map((x) => ({
|
options={Object.keys(TicketStatusLabel).map((x) => ({
|
||||||
value: x,
|
value: x,
|
||||||
label: TicketStatusLabel[x as keyof typeof TicketStatusLabel],
|
label: TicketStatusLabel[x as keyof typeof TicketStatusLabel],
|
||||||
}))}
|
}))}
|
||||||
value={{value: status, label: TicketStatusLabel[status]}}
|
value={{value: status, label: TicketStatusLabel[status]}}
|
||||||
onChange={(value) =>
|
onChange={(value) => setStatus((value?.value as TicketStatus) ?? undefined)}
|
||||||
setStatus((value?.value as TicketStatus) ?? undefined)
|
|
||||||
}
|
|
||||||
placeholder="Status..."
|
placeholder="Status..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex w-full flex-col gap-3">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">
|
<label className="text-mti-gray-dim text-base font-normal">Type</label>
|
||||||
Type
|
|
||||||
</label>
|
|
||||||
<Select
|
<Select
|
||||||
options={Object.keys(TicketTypeLabel).map((x) => ({
|
options={Object.keys(TicketTypeLabel).map((x) => ({
|
||||||
value: x,
|
value: x,
|
||||||
@@ -131,9 +108,7 @@ export default function TicketDisplay({ user, ticket, onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex w-full flex-col gap-3">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">
|
<label className="text-mti-gray-dim text-base font-normal">Assignee</label>
|
||||||
Assignee
|
|
||||||
</label>
|
|
||||||
<Select
|
<Select
|
||||||
options={[
|
options={[
|
||||||
{value: "me", label: "Assign to me"},
|
{value: "me", label: "Assign to me"},
|
||||||
@@ -153,52 +128,20 @@ export default function TicketDisplay({ user, ticket, onClose }: Props) {
|
|||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
onChange={(value) =>
|
onChange={(value) => (value ? setAssignedTo(value.value === "me" ? user.id : value.value) : setAssignedTo(null))}
|
||||||
value
|
|
||||||
? setAssignedTo(value.value === "me" ? user.id : value.value)
|
|
||||||
: setAssignedTo(null)
|
|
||||||
}
|
|
||||||
placeholder="Assignee..."
|
placeholder="Assignee..."
|
||||||
isClearable
|
isClearable
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||||
<Input
|
<Input label="Reported From" type="text" name="reportedFrom" onChange={() => null} value={reportedFrom} disabled />
|
||||||
label="Reported From"
|
<Input label="Date" type="text" name="date" onChange={() => null} value={moment(ticket.date).format("DD/MM/YYYY - HH:mm")} disabled />
|
||||||
type="text"
|
|
||||||
name="reportedFrom"
|
|
||||||
onChange={() => null}
|
|
||||||
value={reportedFrom}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Date"
|
|
||||||
type="text"
|
|
||||||
name="date"
|
|
||||||
onChange={() => null}
|
|
||||||
value={moment(ticket.date).format("DD/MM/YYYY - HH:mm")}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||||
<Input
|
<Input label="Reporter's Name" type="text" name="reporter" onChange={() => null} value={reporter.name} disabled />
|
||||||
label="Reporter's Name"
|
<Input label="Reporter's E-mail" type="text" name="reporter" onChange={() => null} value={reporter.email} disabled />
|
||||||
type="text"
|
|
||||||
name="reporter"
|
|
||||||
onChange={() => null}
|
|
||||||
value={reporter.name}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Reporter's E-mail"
|
|
||||||
type="text"
|
|
||||||
name="reporter"
|
|
||||||
onChange={() => null}
|
|
||||||
value={reporter.email}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<Input
|
<Input
|
||||||
label="Reporter's Type"
|
label="Reporter's Type"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -218,34 +161,15 @@ export default function TicketDisplay({ user, ticket, onClose }: Props) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="-md:flex-col-reverse mt-2 flex w-full items-center justify-between gap-4">
|
<div className="-md:flex-col-reverse mt-2 flex w-full items-center justify-between gap-4">
|
||||||
<Button
|
<Button type="button" color="red" className="w-full md:max-w-[200px]" variant="outline" onClick={del} isLoading={isLoading}>
|
||||||
type="button"
|
|
||||||
color="red"
|
|
||||||
className="w-full md:max-w-[200px]"
|
|
||||||
variant="outline"
|
|
||||||
onClick={del}
|
|
||||||
isLoading={isLoading}
|
|
||||||
>
|
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="-md:flex-col-reverse flex w-full items-center justify-end gap-4">
|
<div className="-md:flex-col-reverse flex w-full items-center justify-end gap-4">
|
||||||
<Button
|
<Button type="button" color="red" className="w-full md:max-w-[200px]" variant="outline" onClick={onClose} isLoading={isLoading}>
|
||||||
type="button"
|
|
||||||
color="red"
|
|
||||||
className="w-full md:max-w-[200px]"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onClose}
|
|
||||||
isLoading={isLoading}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="button" className="w-full md:max-w-[200px]" isLoading={isLoading} onClick={submit}>
|
||||||
type="button"
|
|
||||||
className="w-full md:max-w-[200px]"
|
|
||||||
isLoading={isLoading}
|
|
||||||
onClick={submit}
|
|
||||||
>
|
|
||||||
Update
|
Update
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
47
src/components/HighlightContent.tsx
Normal file
47
src/components/HighlightContent.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import React, { useCallback } from "react";
|
||||||
|
import { HighlightConfig, HighlightTarget } from "@/training/TrainingInterfaces";
|
||||||
|
|
||||||
|
interface HighlightedContentProps {
|
||||||
|
html: string;
|
||||||
|
highlightConfigs: HighlightConfig[];
|
||||||
|
contentType: HighlightTarget;
|
||||||
|
currentSegmentIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HighlightedContent: React.FC<HighlightedContentProps> = ({
|
||||||
|
html,
|
||||||
|
highlightConfigs,
|
||||||
|
contentType,
|
||||||
|
currentSegmentIndex
|
||||||
|
}) => {
|
||||||
|
const createHighlightedContent = useCallback(() => {
|
||||||
|
let highlightedHtml = html;
|
||||||
|
highlightConfigs.forEach(config => {
|
||||||
|
if (config.targets.includes(contentType) || config.targets.includes('all')) {
|
||||||
|
const escapeRegExp = (string: string) => {
|
||||||
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
};
|
||||||
|
|
||||||
|
const regex = new RegExp(config.phrases.map(escapeRegExp).join('|'), 'g');
|
||||||
|
|
||||||
|
if (contentType === 'segment' && currentSegmentIndex !== undefined) {
|
||||||
|
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 };
|
||||||
|
}, [html, highlightConfigs, contentType, currentSegmentIndex]);
|
||||||
|
|
||||||
|
return <div dangerouslySetInnerHTML={createHighlightedContent()} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HighlightedContent;
|
||||||
77
src/components/List.tsx
Normal file
77
src/components/List.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import {Column, flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table";
|
||||||
|
import {useMemo, useState} from "react";
|
||||||
|
import Button from "./Low/Button";
|
||||||
|
|
||||||
|
const SIZE = 25;
|
||||||
|
|
||||||
|
export default function List<T>({data, columns}: {data: T[]; columns: any[]}) {
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
|
||||||
|
const items = useMemo(() => data.slice(page * SIZE, (page + 1) * SIZE > data.length ? data.length : (page + 1) * SIZE), [data, page]);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: items,
|
||||||
|
columns: columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex flex-col gap-2">
|
||||||
|
<div className="w-full flex gap-2 justify-between">
|
||||||
|
<Button 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 * SIZE + 1} - {(page + 1) * SIZE > data.length ? data.length : (page + 1) * SIZE} / {data.length}
|
||||||
|
</span>
|
||||||
|
<Button className="w-[200px]" disabled={(page + 1) * SIZE >= data.length} onClick={() => setPage((prev) => prev + 1)}>
|
||||||
|
Next Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||||
|
<thead>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<th key={header.id} colSpan={header.colSpan}>
|
||||||
|
{header.isPlaceholder ? null : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
{...{
|
||||||
|
className: header.column.getCanSort()
|
||||||
|
? "cursor-pointer select-none py-4 text-left first:pl-4"
|
||||||
|
: "",
|
||||||
|
onClick: header.column.getToggleSortingHandler(),
|
||||||
|
}}>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{{
|
||||||
|
asc: " 🔼",
|
||||||
|
desc: " 🔽",
|
||||||
|
}[header.column.getIsSorted() as string] ?? null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody className="px-2">
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td className="px-4 py-2" key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ interface Props {
|
|||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
padding?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
type?: "button" | "reset" | "submit";
|
type?: "button" | "reset" | "submit";
|
||||||
}
|
}
|
||||||
@@ -21,6 +22,7 @@ export default function Button({
|
|||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
type,
|
type,
|
||||||
|
padding = "py-4 px-6",
|
||||||
onClick,
|
onClick,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const colorClassNames: {[key in typeof color]: {[key in typeof variant]: string}} = {
|
const colorClassNames: {[key in typeof color]: {[key in typeof variant]: string}} = {
|
||||||
@@ -61,7 +63,8 @@ export default function Button({
|
|||||||
type={type}
|
type={type}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"py-4 px-6 rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed cursor-pointer",
|
"rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed cursor-pointer select-none",
|
||||||
|
padding,
|
||||||
colorClassNames[color][variant],
|
colorClassNames[color][variant],
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,14 +11,16 @@ interface Props {
|
|||||||
|
|
||||||
export default function Checkbox({isChecked, onChange, children, disabled}: Props) {
|
export default function Checkbox({isChecked, onChange, children, disabled}: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer" onClick={() => {
|
<div
|
||||||
|
className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
onChange(!isChecked);
|
onChange(!isChecked);
|
||||||
}}>
|
}}>
|
||||||
<input type="checkbox" className="hidden" />
|
<input type="checkbox" className="hidden" />
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
|
"w-6 h-6 min-w-6 min-h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
isChecked && "!bg-mti-purple-light ",
|
isChecked && "!bg-mti-purple-light ",
|
||||||
)}>
|
)}>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import clsx from "clsx";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
type: "email" | "text" | "password" | "tel" | "number";
|
type: "email" | "text" | "password" | "tel" | "number" | "textarea";
|
||||||
roundness?: "full" | "xl";
|
roundness?: "full" | "xl";
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -11,6 +11,7 @@ interface Props {
|
|||||||
value?: string | number;
|
value?: string | number;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
max?: number;
|
||||||
name: string;
|
name: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
@@ -23,6 +24,7 @@ export default function Input({
|
|||||||
required = false,
|
required = false,
|
||||||
value,
|
value,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
|
max,
|
||||||
className,
|
className,
|
||||||
roundness = "full",
|
roundness = "full",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -30,6 +32,20 @@ export default function Input({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
if (type === "textarea") {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
className="w-full h-full cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl min-h-[200px]"
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (type === "password") {
|
if (type === "password") {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
@@ -72,6 +88,7 @@ export default function Input({
|
|||||||
name={name}
|
name={name}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
value={value}
|
value={value}
|
||||||
|
max={max}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
min={type === "number" ? 0 : undefined}
|
min={type === "number" ? 0 : undefined}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
|||||||
@@ -1,49 +1,53 @@
|
|||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import { moduleLabels } from "@/utils/moduleUtils";
|
import { moduleLabels } from "@/utils/moduleUtils";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {motion} from "framer-motion";
|
import { ReactNode, useState } from "react";
|
||||||
import {ReactNode, useEffect, useState} from "react";
|
|
||||||
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch } from "react-icons/bs";
|
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch } from "react-icons/bs";
|
||||||
import ProgressBar from "../Low/ProgressBar";
|
import ProgressBar from "../Low/ProgressBar";
|
||||||
import TimerEndedModal from "../TimerEndedModal";
|
import Timer from "./Timer";
|
||||||
|
import { Exercise, LevelExam, MultipleChoiceExercise, ShuffleMap, UserSolution } from "@/interfaces/exam";
|
||||||
|
import { BsFillGrid3X3GapFill } from "react-icons/bs";
|
||||||
|
import Button from "../Low/Button";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
module: Module;
|
module: Module;
|
||||||
|
examLabel?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
exerciseIndex: number;
|
exerciseIndex: number;
|
||||||
totalExercises: number;
|
totalExercises: number;
|
||||||
disableTimer?: boolean;
|
disableTimer?: boolean;
|
||||||
|
partLabel?: string;
|
||||||
|
showTimer?: boolean;
|
||||||
|
showSolutions?: boolean;
|
||||||
|
currentExercise?: Exercise;
|
||||||
|
runOnClick?: ((questionIndex: number) => void) | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ModuleTitle({minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false}: Props) {
|
export default function ModuleTitle({
|
||||||
const [timer, setTimer] = useState(minTimer * 60);
|
minTimer,
|
||||||
const [showModal, setShowModal] = useState(false);
|
module,
|
||||||
const [warningMode, setWarningMode] = useState(false);
|
label,
|
||||||
|
examLabel,
|
||||||
|
exerciseIndex,
|
||||||
|
totalExercises,
|
||||||
|
disableTimer = false,
|
||||||
|
partLabel,
|
||||||
|
showTimer = true,
|
||||||
|
showSolutions = false,
|
||||||
|
runOnClick = undefined
|
||||||
|
}: Props) {
|
||||||
|
const {
|
||||||
|
userSolutions,
|
||||||
|
partIndex,
|
||||||
|
exam
|
||||||
|
} = useExamStore((state) => state);
|
||||||
|
const examExerciseIndex = useExamStore((state) => state.exerciseIndex)
|
||||||
|
|
||||||
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const {timeSpent} = useExamStore((state) => state);
|
|
||||||
|
|
||||||
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!disableTimer) {
|
|
||||||
const timerInterval = setInterval(() => setTimer((prev) => prev - 1), 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(timerInterval);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [disableTimer, minTimer]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (timer <= 0) setShowModal(true);
|
|
||||||
}, [timer]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (timer < 300 && !warningMode) setWarningMode(true);
|
|
||||||
}, [timer, warningMode]);
|
|
||||||
|
|
||||||
const moduleIcon: { [key in Module]: ReactNode } = {
|
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" />,
|
||||||
@@ -53,52 +57,134 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
|
|||||||
level: <BsClipboard className="text-ielts-level w-6 h-6" />,
|
level: <BsClipboard className="text-ielts-level w-6 h-6" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isMultipleChoiceLevelExercise = () => {
|
||||||
|
if (exam?.module === 'level' && typeof partIndex === "number" && partIndex > -1) {
|
||||||
|
const currentExercise = (exam as LevelExam).parts[partIndex].exercises[examExerciseIndex];
|
||||||
|
return currentExercise && currentExercise.type === 'multipleChoice';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMCQuestionGrid = () => {
|
||||||
|
if (!isMultipleChoiceLevelExercise() && !userSolutions) return null;
|
||||||
|
|
||||||
|
const currentExercise = (exam as LevelExam).parts[partIndex!].exercises[examExerciseIndex] as MultipleChoiceExercise;
|
||||||
|
const userSolution = userSolutions!.find((x) => x.exercise.toString() == currentExercise.id.toString())!;
|
||||||
|
const answeredQuestions = new Set(userSolution.solutions.map(sol => sol.question.toString()));
|
||||||
|
const exerciseOffset = Number(currentExercise.questions[0].id);
|
||||||
|
const lastExercise = exerciseOffset + (currentExercise.questions.length - 1);
|
||||||
|
|
||||||
|
const getQuestionColor = (questionId: string, solution: string, userQuestionSolution: string | undefined) => {
|
||||||
|
const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => {
|
||||||
|
if (foundMap) return foundMap;
|
||||||
|
return userSolution.shuffleMaps?.find(map => map.questionID.toString() === questionId.toString()) || null;
|
||||||
|
}, null as ShuffleMap | null);
|
||||||
|
const newSolution = questionShuffleMap ? questionShuffleMap?.map[solution] : solution;
|
||||||
|
|
||||||
|
if (!userSolutions) return "";
|
||||||
|
|
||||||
|
if (!userQuestionSolution) {
|
||||||
|
return "!bg-mti-gray-davy !border--mti-gray-davy !text-mti-gray-davy !text-white hover:!bg-gray-700";
|
||||||
|
}
|
||||||
|
|
||||||
|
return userQuestionSolution === newSolution ?
|
||||||
|
"!bg-mti-purple-light !text-mti-purple-light !text-white hover:!bg-mti-purple-dark" :
|
||||||
|
"!bg-mti-rose-light !border-mti-rose-light !text-mti-rose-light !text-white hover:!bg-mti-rose-dark";
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TimerEndedModal
|
<h3 className="text-xl font-semibold mb-4 text-center">{`Part ${partIndex + 1} (Questions ${exerciseOffset} - ${lastExercise})`}</h3>
|
||||||
isOpen={showModal}
|
<div className="grid grid-cols-5 gap-3 px-4 py-2">
|
||||||
onClose={() => {
|
{currentExercise.questions.map((_, index) => {
|
||||||
setHasExamEnded(true);
|
const questionNumber = exerciseOffset + index;
|
||||||
setShowModal(false);
|
const isAnswered = answeredQuestions.has(questionNumber.toString());
|
||||||
}}
|
const solution = currentExercise.questions.find((x) => x.id.toString() == questionNumber.toString())!.solution;
|
||||||
/>
|
|
||||||
<motion.div
|
const userQuestionSolution = currentExercise.userSolutions?.find((x) => x.question.toString() == questionNumber.toString())?.option;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant={showSolutions ? "solid" : (isAnswered ? "solid" : "outline")}
|
||||||
|
key={index}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"absolute top-4 right-6 bg-mti-gray-seasalt px-4 py-3 flex items-center gap-2 rounded-full text-mti-gray-davy",
|
"w-12 h-12 flex items-center justify-center rounded-lg text-sm font-bold transition-all duration-200 ease-in-out",
|
||||||
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt",
|
(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"
|
||||||
|
)
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
initial={{scale: warningMode && !disableTimer ? 0.8 : 1}}
|
onClick={() => { if (typeof runOnClick !== "undefined") { runOnClick(index); } setIsOpen(false); }}
|
||||||
animate={{scale: warningMode && !disableTimer ? 1.1 : 1}}
|
>
|
||||||
transition={{repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut"}}>
|
{questionNumber}
|
||||||
<BsStopwatch className="w-6 h-6" />
|
</Button>
|
||||||
<span className="text-base font-semibold w-12">
|
);
|
||||||
{timer > 0 && (
|
})}
|
||||||
<>
|
</div>
|
||||||
{Math.floor(timer / 60)
|
<p className="mt-4 text-sm text-gray-600 text-center">
|
||||||
.toString(10)
|
Click a question number to jump to that question
|
||||||
.padStart(2, "0")}
|
</p>
|
||||||
:
|
|
||||||
{Math.floor(timer % 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
</>
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />}
|
||||||
|
<div className="w-full">
|
||||||
|
{partLabel && (
|
||||||
|
<div className="text-3xl space-y-4">
|
||||||
|
{partLabel.split("\n\n").map((partInstructions, index) => {
|
||||||
|
if (index === 0)
|
||||||
|
return (
|
||||||
|
<p key={index} className="font-bold">
|
||||||
|
{partInstructions}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
else
|
||||||
|
return (
|
||||||
|
<div key={index} className="text-2xl font-semibold flex flex-col gap-2">
|
||||||
|
{partInstructions.split("\\n").map((line, lineIndex) => (
|
||||||
|
<span key={lineIndex} dangerouslySetInnerHTML={{__html: line.replace('that is not correct', 'that is <span class="font-bold"><u>not correct</u></span>')}}></span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{timer <= 0 && <>00:00</>}
|
<div className={clsx("flex gap-6 w-full h-fit items-center", partLabel ? "mt-10" : "mt-5")}>
|
||||||
</span>
|
|
||||||
</motion.div>
|
|
||||||
<div className="flex gap-6 w-full h-fit items-center mt-5">
|
|
||||||
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
|
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="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">
|
||||||
Exercise {exerciseIndex}/{totalExercises}
|
Question {exerciseIndex}/{totalExercises}
|
||||||
</span>
|
</span>
|
||||||
</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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
206
src/components/Medium/RecordFilter.tsx
Normal file
206
src/components/Medium/RecordFilter.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { User } from "@/interfaces/user";
|
||||||
|
import { checkAccess } from "@/utils/permissions";
|
||||||
|
import Select from "../Low/Select";
|
||||||
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
import useRecordStore from "@/stores/recordStore";
|
||||||
|
|
||||||
|
|
||||||
|
type TimeFilter = "months" | "weeks" | "days";
|
||||||
|
type Filter = TimeFilter | "assignments" | undefined;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
filterState: {
|
||||||
|
filter: Filter,
|
||||||
|
setFilter: React.Dispatch<React.SetStateAction<Filter>>
|
||||||
|
},
|
||||||
|
assignments?: boolean;
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultSelectableCorporate = {
|
||||||
|
value: "",
|
||||||
|
label: "All",
|
||||||
|
};
|
||||||
|
|
||||||
|
const RecordFilter: React.FC<Props> = ({
|
||||||
|
user,
|
||||||
|
filterState,
|
||||||
|
assignments = true,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
const { filter, setFilter } = filterState;
|
||||||
|
|
||||||
|
const [statsUserId, setStatsUserId] = useRecordStore((state) => [
|
||||||
|
state.selectedUser,
|
||||||
|
state.setSelectedUser
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { users } = useUsers();
|
||||||
|
const { groups: allGroups } = useGroups({});
|
||||||
|
const { groups } = useGroups({ admin: user?.id, userType: user?.type });
|
||||||
|
|
||||||
|
const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => {
|
||||||
|
setFilter((prev) => (prev === value ? undefined : value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectableCorporates = [
|
||||||
|
defaultSelectableCorporate,
|
||||||
|
...users
|
||||||
|
.filter((x) => groups.flatMap((g) => [g.admin, ...g.participants]).includes(x.id))
|
||||||
|
.filter((x) => x.type === "corporate")
|
||||||
|
.map((x) => ({
|
||||||
|
value: x.id,
|
||||||
|
label: `${x.name} - ${x.email}`,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
const [selectedCorporate, setSelectedCorporate] = useState<string>(defaultSelectableCorporate.value);
|
||||||
|
|
||||||
|
const getUsersList = (): User[] => {
|
||||||
|
if (selectedCorporate) {
|
||||||
|
const selectedCorporateGroups = allGroups.filter((x) => x.admin === selectedCorporate);
|
||||||
|
const selectedCorporateGroupsParticipants = selectedCorporateGroups.flatMap((x) => x.participants);
|
||||||
|
|
||||||
|
const userListWithUsers = selectedCorporateGroupsParticipants.map((x) => users.find((y) => y.id === x)) as User[];
|
||||||
|
return userListWithUsers.filter((x) => x);
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.type !== "mastercorporate" ? users : users.filter((x) => groups.flatMap((g) => [g.admin, ...g.participants]).includes(x.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const corporateFilteredUserList = getUsersList();
|
||||||
|
|
||||||
|
const getSelectedUser = () => {
|
||||||
|
if (selectedCorporate) {
|
||||||
|
const userInCorporate = corporateFilteredUserList.find((x) => x.id === statsUserId);
|
||||||
|
return userInCorporate || corporateFilteredUserList[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return users.find((x) => x.id === statsUserId) || user;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedUser = getSelectedUser();
|
||||||
|
const selectedUserSelectValue = selectedUser
|
||||||
|
? {
|
||||||
|
value: selectedUser.id,
|
||||||
|
label: `${selectedUser.name} - ${selectedUser.email}`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
value: "",
|
||||||
|
label: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center">
|
||||||
|
<div className="xl:w-3/4">
|
||||||
|
{checkAccess(user, ["developer", "admin", "mastercorporate"]) && !children && (
|
||||||
|
<>
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Corporate</label>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
options={selectableCorporates}
|
||||||
|
value={selectableCorporates.find((x) => x.value === selectedCorporate)}
|
||||||
|
onChange={(value) => setSelectedCorporate(value?.value || "")}
|
||||||
|
styles={{
|
||||||
|
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||||
|
option: (styles, state) => ({
|
||||||
|
...styles,
|
||||||
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
|
color: state.isFocused ? "black" : styles.color,
|
||||||
|
}),
|
||||||
|
}}></Select>
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">User</label>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
options={corporateFilteredUserList.map((x) => ({
|
||||||
|
value: x.id,
|
||||||
|
label: `${x.name} - ${x.email}`,
|
||||||
|
}))}
|
||||||
|
value={selectedUserSelectValue}
|
||||||
|
onChange={(value) => setStatsUserId(value?.value!)}
|
||||||
|
styles={{
|
||||||
|
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||||
|
option: (styles, state) => ({
|
||||||
|
...styles,
|
||||||
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
|
color: state.isFocused ? "black" : styles.color,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(user.type === "corporate" || user.type === "teacher") && groups.length > 0 && !children && (
|
||||||
|
<>
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">User</label>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
options={users
|
||||||
|
.filter((x) => groups.flatMap((y) => y.participants).includes(x.id))
|
||||||
|
.map((x) => ({
|
||||||
|
value: x.id,
|
||||||
|
label: `${x.name} - ${x.email}`,
|
||||||
|
}))}
|
||||||
|
value={selectedUserSelectValue}
|
||||||
|
onChange={(value) => setStatsUserId(value?.value!)}
|
||||||
|
styles={{
|
||||||
|
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||||
|
option: (styles, state) => ({
|
||||||
|
...styles,
|
||||||
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
|
color: state.isFocused ? "black" : styles.color,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 w-full justify-center xl:justify-end">
|
||||||
|
{assignments && (
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
filter === "assignments" && "!bg-mti-purple-light !text-white",
|
||||||
|
)}
|
||||||
|
onClick={() => toggleFilter("assignments")}>
|
||||||
|
Assignments
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
filter === "months" && "!bg-mti-purple-light !text-white",
|
||||||
|
)}
|
||||||
|
onClick={() => toggleFilter("months")}>
|
||||||
|
Last month
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
filter === "weeks" && "!bg-mti-purple-light !text-white",
|
||||||
|
)}
|
||||||
|
onClick={() => toggleFilter("weeks")}>
|
||||||
|
Last week
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
filter === "days" && "!bg-mti-purple-light !text-white",
|
||||||
|
)}
|
||||||
|
onClick={() => toggleFilter("days")}>
|
||||||
|
Last day
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RecordFilter;
|
||||||
@@ -40,7 +40,8 @@ export default function SessionCard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-mti-gray-anti-flash flex w-64 flex-col gap-3 rounded-xl border p-4 text-black">
|
<div className="border-mti-gray-anti-flash flex w-64 flex-col justify-between gap-3 rounded-xl border p-4 text-black">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
<span className="flex gap-1">
|
<span className="flex gap-1">
|
||||||
<b>ID:</b>
|
<b>ID:</b>
|
||||||
{session.sessionId}
|
{session.sessionId}
|
||||||
@@ -49,6 +50,14 @@ export default function SessionCard({
|
|||||||
<b>Date:</b>
|
<b>Date:</b>
|
||||||
{moment(session.date).format("DD/MM/YYYY - HH:mm")}
|
{moment(session.date).format("DD/MM/YYYY - HH:mm")}
|
||||||
</span>
|
</span>
|
||||||
|
{session.assignment && (
|
||||||
|
<span className="flex flex-col gap-0">
|
||||||
|
<b>Assignment:</b>
|
||||||
|
{session.assignment.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-center justify-center gap-2">
|
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-center justify-center gap-2">
|
||||||
{session.selectedModules.sort(sortByModuleName).map((module) => (
|
{session.selectedModules.sort(sortByModuleName).map((module) => (
|
||||||
@@ -97,5 +106,6 @@ export default function SessionCard({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
313
src/components/Medium/StatGridItem.tsx
Normal file
313
src/components/Medium/StatGridItem.tsx
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {BsClock, BsXCircle} from "react-icons/bs";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {Stat, User} from "@/interfaces/user";
|
||||||
|
import {Module, Step} from "@/interfaces";
|
||||||
|
import ai_usage from "@/utils/ai.detection";
|
||||||
|
import {calculateBandScore} from "@/utils/score";
|
||||||
|
import moment from "moment";
|
||||||
|
import {Assignment} from "@/interfaces/results";
|
||||||
|
import {uuidv4} from "@firebase/util";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import {uniqBy} from "lodash";
|
||||||
|
import {sortByModule} from "@/utils/moduleUtils";
|
||||||
|
import {convertToUserSolutions} from "@/utils/stats";
|
||||||
|
import {getExamById} from "@/utils/exams";
|
||||||
|
import {Exam, UserSolution} from "@/interfaces/exam";
|
||||||
|
import ModuleBadge from "../ModuleBadge";
|
||||||
|
|
||||||
|
const formatTimestamp = (timestamp: string | number) => {
|
||||||
|
const time = typeof timestamp === "string" ? parseInt(timestamp) : timestamp;
|
||||||
|
const date = moment(time);
|
||||||
|
const formatter = "YYYY/MM/DD - HH:mm";
|
||||||
|
return date.format(formatter);
|
||||||
|
};
|
||||||
|
|
||||||
|
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
||||||
|
const scores: {
|
||||||
|
[key in Module]: {total: number; missing: number; correct: number};
|
||||||
|
} = {
|
||||||
|
reading: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
listening: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
writing: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
speaking: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
level: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
stats.forEach((x) => {
|
||||||
|
scores[x.module!] = {
|
||||||
|
total: scores[x.module!].total + x.score.total,
|
||||||
|
correct: scores[x.module!].correct + x.score.correct,
|
||||||
|
missing: scores[x.module!].missing + x.score.missing,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.keys(scores)
|
||||||
|
.filter((x) => scores[x as Module].total > 0)
|
||||||
|
.map((x) => ({module: x as Module, ...scores[x as Module]}));
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StatsGridItemProps {
|
||||||
|
width?: string | undefined;
|
||||||
|
height?: string | undefined;
|
||||||
|
examNumber?: number | undefined;
|
||||||
|
stats: Stat[];
|
||||||
|
timestamp: string | number;
|
||||||
|
user: User;
|
||||||
|
assignments: Assignment[];
|
||||||
|
users: User[];
|
||||||
|
training?: boolean;
|
||||||
|
gradingSystem?: Step[];
|
||||||
|
selectedTrainingExams?: string[];
|
||||||
|
maxTrainingExams?: number;
|
||||||
|
setSelectedTrainingExams?: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
|
setExams: (exams: Exam[]) => void;
|
||||||
|
setShowSolutions: (show: boolean) => void;
|
||||||
|
setUserSolutions: (solutions: UserSolution[]) => void;
|
||||||
|
setSelectedModules: (modules: Module[]) => void;
|
||||||
|
setInactivity: (inactivity: number) => void;
|
||||||
|
setTimeSpent: (time: number) => void;
|
||||||
|
renderPdfIcon: (session: string, color: string, textColor: string) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatsGridItem: React.FC<StatsGridItemProps> = ({
|
||||||
|
stats,
|
||||||
|
timestamp,
|
||||||
|
user,
|
||||||
|
assignments,
|
||||||
|
users,
|
||||||
|
training,
|
||||||
|
selectedTrainingExams,
|
||||||
|
gradingSystem,
|
||||||
|
setSelectedTrainingExams,
|
||||||
|
setExams,
|
||||||
|
setShowSolutions,
|
||||||
|
setUserSolutions,
|
||||||
|
setSelectedModules,
|
||||||
|
setInactivity,
|
||||||
|
setTimeSpent,
|
||||||
|
renderPdfIcon,
|
||||||
|
width = undefined,
|
||||||
|
height = undefined,
|
||||||
|
examNumber = undefined,
|
||||||
|
maxTrainingExams = undefined,
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
||||||
|
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
||||||
|
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
||||||
|
const assignmentID = stats.reduce((_, current) => current.assignment as any, "");
|
||||||
|
const assignment = assignments.find((a) => a.id === assignmentID);
|
||||||
|
const isDisabled = stats.some((x) => x.isDisabled);
|
||||||
|
|
||||||
|
const aiUsage = Math.round(ai_usage(stats) * 100);
|
||||||
|
|
||||||
|
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||||
|
module: x.module,
|
||||||
|
level: calculateBandScore(x.correct, x.total, x.module, user.focus),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const textColor = clsx(
|
||||||
|
correct / total >= 0.7 && "text-mti-purple",
|
||||||
|
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||||
|
correct / total < 0.3 && "text-mti-rose",
|
||||||
|
);
|
||||||
|
|
||||||
|
const {timeSpent, inactivity, session} = stats[0];
|
||||||
|
|
||||||
|
const selectExam = () => {
|
||||||
|
if (
|
||||||
|
training &&
|
||||||
|
!isDisabled &&
|
||||||
|
typeof maxTrainingExams !== "undefined" &&
|
||||||
|
typeof setSelectedTrainingExams !== "undefined" &&
|
||||||
|
typeof timestamp == "string"
|
||||||
|
) {
|
||||||
|
setSelectedTrainingExams((prevExams) => {
|
||||||
|
const uniqueExams = [...new Set(stats.map((stat) => `${stat.module}-${stat.date}`))];
|
||||||
|
const indexes = uniqueExams.map((exam) => prevExams.indexOf(exam)).filter((index) => index !== -1);
|
||||||
|
if (indexes.length > 0) {
|
||||||
|
const newExams = [...prevExams];
|
||||||
|
indexes
|
||||||
|
.sort((a, b) => b - a)
|
||||||
|
.forEach((index) => {
|
||||||
|
newExams.splice(index, 1);
|
||||||
|
});
|
||||||
|
return newExams;
|
||||||
|
} else {
|
||||||
|
if (prevExams.length + uniqueExams.length <= maxTrainingExams) {
|
||||||
|
return [...prevExams, ...uniqueExams];
|
||||||
|
} else {
|
||||||
|
return prevExams;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const examPromises = uniqBy(stats, "exam").map((stat) => {
|
||||||
|
return getExamById(stat.module, stat.exam);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDisabled) return;
|
||||||
|
|
||||||
|
Promise.all(examPromises).then((exams) => {
|
||||||
|
if (exams.every((x) => !!x)) {
|
||||||
|
if (!!timeSpent) setTimeSpent(timeSpent);
|
||||||
|
if (!!inactivity) setInactivity(inactivity);
|
||||||
|
setUserSolutions(convertToUserSolutions(stats));
|
||||||
|
setShowSolutions(true);
|
||||||
|
setExams(exams.map((x) => x!).sort(sortByModule));
|
||||||
|
setSelectedModules(
|
||||||
|
exams
|
||||||
|
.map((x) => x!)
|
||||||
|
.sort(sortByModule)
|
||||||
|
.map((x) => x!.module),
|
||||||
|
);
|
||||||
|
router.push("/exercises");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldRenderPDFIcon = () => {
|
||||||
|
if (assignment) {
|
||||||
|
return assignment.released;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
|
||||||
|
<div className="flex flex-col md:gap-1 -md:gap-2 2xl:gap-2">
|
||||||
|
<span className="font-medium">{formatTimestamp(timestamp)}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!!timeSpent && (
|
||||||
|
<span className="text-sm flex gap-2 items-center tooltip" data-tip="Time Spent">
|
||||||
|
<BsClock /> {Math.floor(timeSpent / 60)} minutes
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!!inactivity && (
|
||||||
|
<span className="text-sm flex gap-2 items-center tooltip" data-tip="Inactivity">
|
||||||
|
<BsXCircle /> {Math.floor(inactivity / 60)} minutes
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
{!!assignment && (assignment.released || assignment.released === undefined) && (
|
||||||
|
<span className={textColor}>
|
||||||
|
Level{" "}
|
||||||
|
{(
|
||||||
|
aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length
|
||||||
|
).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{shouldRenderPDFIcon() && renderPdfIcon(session, textColor, textColor)}
|
||||||
|
</div>
|
||||||
|
{examNumber === undefined ? (
|
||||||
|
<>
|
||||||
|
{aiUsage >= 50 && user.type !== "student" && (
|
||||||
|
<div
|
||||||
|
className={clsx("ml-auto border px-1 rounded w-fit mr-1", {
|
||||||
|
"bg-orange-100 border-orange-400 text-orange-700": aiUsage < 80,
|
||||||
|
"bg-red-100 border-red-400 text-red-700": aiUsage >= 80,
|
||||||
|
})}>
|
||||||
|
<span className="text-xs">AI Usage</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<span className="font-semibold bg-gray-200 text-gray-800 px-2.5 py-0.5 rounded-full mt-0.5">{examNumber}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full flex flex-col gap-1">
|
||||||
|
<div className={clsx("grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2", examNumber !== undefined && "pr-10")}>
|
||||||
|
{!!assignment &&
|
||||||
|
(assignment.released || assignment.released === undefined) &&
|
||||||
|
aggregatedLevels.map(({module, level}) => <ModuleBadge key={module} module={module} level={level} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{assignment && (
|
||||||
|
<span className="font-light text-sm">
|
||||||
|
Assignment: {assignment.name}, Teacher: {users.find((u) => u.id === assignment.assigner)?.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
key={uuidv4()}
|
||||||
|
className={clsx(
|
||||||
|
"flex flex-col justify-between gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden",
|
||||||
|
(isDisabled || (!!assignment && !assignment.released)) && "grayscale tooltip",
|
||||||
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
|
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||||
|
correct / total < 0.3 && "hover:border-mti-rose",
|
||||||
|
typeof selectedTrainingExams !== "undefined" &&
|
||||||
|
typeof timestamp === "string" &&
|
||||||
|
selectedTrainingExams.some((exam) => exam.includes(timestamp)) &&
|
||||||
|
"border-2 border-slate-600",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!!assignment && !assignment.released) return;
|
||||||
|
if (examNumber === undefined) return selectExam();
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
...(width !== undefined && {width}),
|
||||||
|
...(height !== undefined && {height}),
|
||||||
|
}}
|
||||||
|
data-tip={isDisabled ? "This exam is still being evaluated..." : "This exam is still locked by its assigner..."}
|
||||||
|
role="button">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
key={uuidv4()}
|
||||||
|
className={clsx(
|
||||||
|
"flex flex-col gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:tooltip md:hidden",
|
||||||
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
|
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||||
|
correct / total < 0.3 && "hover:border-mti-rose",
|
||||||
|
)}
|
||||||
|
data-tip="Your screen size is too small to view previous exams."
|
||||||
|
style={{
|
||||||
|
...(width !== undefined && {width}),
|
||||||
|
...(height !== undefined && {height}),
|
||||||
|
}}
|
||||||
|
role="button">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatsGridItem;
|
||||||
80
src/components/Medium/Timer.tsx
Normal file
80
src/components/Medium/Timer.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import {motion} from "framer-motion";
|
||||||
|
import TimerEndedModal from "../TimerEndedModal";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {BsStopwatch} from "react-icons/bs";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
minTimer: number;
|
||||||
|
disableTimer?: boolean;
|
||||||
|
standalone?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Timer: React.FC<Props> = ({minTimer, disableTimer, standalone = false}) => {
|
||||||
|
const [timer, setTimer] = useState(minTimer * 60);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [warningMode, setWarningMode] = useState(false);
|
||||||
|
|
||||||
|
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
||||||
|
const {timeSpent} = useExamStore((state) => state);
|
||||||
|
|
||||||
|
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!disableTimer) {
|
||||||
|
const timerInterval = setInterval(() => setTimer((prev) => prev - 1), 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(timerInterval);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [disableTimer, minTimer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timer <= 0) setShowModal(true);
|
||||||
|
}, [timer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timer < 300 && !warningMode) setWarningMode(true);
|
||||||
|
}, [timer, warningMode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TimerEndedModal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={() => {
|
||||||
|
setHasExamEnded(true);
|
||||||
|
setShowModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className={clsx(
|
||||||
|
"absolute right-6 bg-mti-gray-seasalt px-4 py-3 flex items-center gap-2 rounded-full text-mti-gray-davy",
|
||||||
|
standalone ? "top-10" : "top-4",
|
||||||
|
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt",
|
||||||
|
)}
|
||||||
|
initial={{scale: warningMode && !disableTimer ? 0.8 : 1}}
|
||||||
|
animate={{scale: warningMode && !disableTimer ? 1.1 : 1}}
|
||||||
|
transition={{repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut"}}>
|
||||||
|
<BsStopwatch className="w-6 h-6" />
|
||||||
|
<span className="text-lg font-bold w-12">
|
||||||
|
{timer > 0 && (
|
||||||
|
<>
|
||||||
|
{Math.floor(timer / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(timer % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{timer <= 0 && <>00:00</>}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Timer;
|
||||||
@@ -7,10 +7,11 @@ interface Props {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
title?: string;
|
title?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
titleClassName?: string;
|
||||||
children?: ReactElement;
|
children?: ReactElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Modal({isOpen, title, className, onClose, children}: Props) {
|
export default function Modal({isOpen, title, className, titleClassName, onClose, children}: Props) {
|
||||||
return (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
<Dialog as="div" className="relative z-[200]" onClose={onClose}>
|
<Dialog as="div" className="relative z-[200]" onClose={onClose}>
|
||||||
@@ -41,7 +42,7 @@ export default function Modal({isOpen, title, className, onClose, children}: Pro
|
|||||||
className,
|
className,
|
||||||
)}>
|
)}>
|
||||||
{title && (
|
{title && (
|
||||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
<Dialog.Title as="h3" className={clsx(titleClassName ? titleClassName : "text-lg font-medium leading-6 text-gray-900")}>
|
||||||
{title}
|
{title}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
|
import {Step} from "@/interfaces";
|
||||||
|
import {getGradingLabel, getLevelLabel} from "@/utils/score";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||||
|
|
||||||
const ModuleBadge: React.FC<{ module: string; level?: number }> = ({ module, level }) => (
|
const ModuleBadge: React.FC<{module: string; level?: number; gradingSystem?: Step[]; className?: string}> = ({
|
||||||
|
module,
|
||||||
|
level,
|
||||||
|
gradingSystem,
|
||||||
|
className,
|
||||||
|
}) => (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
|
"flex gap-2 justify-center items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
|
||||||
module === "reading" && "bg-ielts-reading",
|
module === "reading" && "bg-ielts-reading",
|
||||||
module === "listening" && "bg-ielts-listening",
|
module === "listening" && "bg-ielts-listening",
|
||||||
module === "writing" && "bg-ielts-writing",
|
module === "writing" && "bg-ielts-writing",
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
module === "level" && "bg-ielts-level",
|
module === "level" && "bg-ielts-level",
|
||||||
|
className,
|
||||||
)}>
|
)}>
|
||||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
{module === "reading" && <BsBook className="w-4 h-4" />}
|
||||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
||||||
@@ -17,7 +25,9 @@ const ModuleBadge: React.FC<{ module: string; level?: number }> = ({ module, lev
|
|||||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
||||||
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
||||||
{/* do not switch to level && it will convert the 0.0 to 0*/}
|
{/* do not switch to level && it will convert the 0.0 to 0*/}
|
||||||
{level !== undefined && (<span className="text-sm">{level.toFixed(1)}</span>)}
|
{level !== undefined && (
|
||||||
|
<span className="text-sm">{module === "level" && gradingSystem ? getGradingLabel(level, gradingSystem) : level.toFixed(1)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -19,17 +19,7 @@ import TicketSubmission from "./High/TicketSubmission";
|
|||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import Badge from "./Low/Badge";
|
import Badge from "./Low/Badge";
|
||||||
|
|
||||||
import {
|
import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
||||||
BsArrowRepeat,
|
|
||||||
BsBook,
|
|
||||||
BsCheck,
|
|
||||||
BsCheckCircle,
|
|
||||||
BsClipboard,
|
|
||||||
BsHeadphones,
|
|
||||||
BsMegaphone,
|
|
||||||
BsPen,
|
|
||||||
BsXCircle,
|
|
||||||
} from "react-icons/bs";
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
navDisabled?: boolean;
|
navDisabled?: boolean;
|
||||||
@@ -39,13 +29,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
export default function Navbar({
|
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||||
user,
|
|
||||||
path,
|
|
||||||
navDisabled = false,
|
|
||||||
focusMode = false,
|
|
||||||
onFocusLayerMouseEnter,
|
|
||||||
}: Props) {
|
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
||||||
const [isTicketOpen, setIsTicketOpen] = useState(false);
|
const [isTicketOpen, setIsTicketOpen] = useState(false);
|
||||||
@@ -58,12 +42,9 @@ export default function Navbar({
|
|||||||
const momentDate = moment(date);
|
const momentDate = moment(date);
|
||||||
const today = moment(new Date());
|
const today = moment(new Date());
|
||||||
|
|
||||||
if (today.add(1, "days").isAfter(momentDate))
|
if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light";
|
||||||
return "!bg-mti-red-ultralight border-mti-red-light";
|
if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light";
|
||||||
if (today.add(3, "days").isAfter(momentDate))
|
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light";
|
||||||
return "!bg-mti-rose-ultralight border-mti-rose-light";
|
|
||||||
if (today.add(7, "days").isAfter(momentDate))
|
|
||||||
return "!bg-mti-orange-ultralight border-mti-orange-light";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const showExpirationDate = () => {
|
const showExpirationDate = () => {
|
||||||
@@ -76,70 +57,50 @@ export default function Navbar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user.type !== "student" && user.type !== "teacher")
|
if (user.type !== "student" && user.type !== "teacher") return setDisablePaymentPage(false);
|
||||||
return setDisablePaymentPage(false);
|
isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result));
|
||||||
isUserFromCorporate(user.id).then((result) =>
|
|
||||||
setDisablePaymentPage(result)
|
|
||||||
);
|
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const badges = [
|
const badges = [
|
||||||
{
|
{
|
||||||
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 || 0 >= user.desiredLevels?.reading || 9,
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
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 || 0 >= user.desiredLevels?.listening || 9,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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 || 0 >= user.desiredLevels?.writing || 9,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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 || 0 >= user.desiredLevels?.speaking || 9,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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 || 0 >= user.desiredLevels?.level || 9,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal
|
<Modal isOpen={isTicketOpen} onClose={() => setIsTicketOpen(false)} title="Submit a ticket">
|
||||||
isOpen={isTicketOpen}
|
<TicketSubmission user={user} page={router.asPath} onClose={() => setIsTicketOpen(false)} />
|
||||||
onClose={() => setIsTicketOpen(false)}
|
|
||||||
title="Submit a ticket"
|
|
||||||
>
|
|
||||||
<TicketSubmission
|
|
||||||
user={user}
|
|
||||||
page={router.asPath}
|
|
||||||
onClose={() => setIsTicketOpen(false)}
|
|
||||||
/>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<MobileMenu
|
<MobileMenu disableNavigation={disableNavigation} path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />
|
||||||
disableNavigation={disableNavigation}
|
|
||||||
path={path}
|
|
||||||
isOpen={isMenuOpen}
|
|
||||||
onClose={() => setIsMenuOpen(false)}
|
|
||||||
user={user}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
|
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
|
||||||
<Link
|
<Link href={disableNavigation ? "" : "/"} className=" flex items-center gap-8 md:px-8">
|
||||||
href={disableNavigation ? "" : "/"}
|
|
||||||
className=" flex items-center gap-8 md:px-8"
|
|
||||||
>
|
|
||||||
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
|
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
|
||||||
<h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1>
|
<h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -148,8 +109,9 @@ export default function Navbar({
|
|||||||
badges.map((badge) => (
|
badges.map((badge) => (
|
||||||
<div
|
<div
|
||||||
key={badge.module}
|
key={badge.module}
|
||||||
className={`${badge.achieved ? `bg-ielts-${badge.module}`: 'bg-mti-gray-anti-flash'} flex h-8 w-8 items-center justify-center rounded-full`}
|
className={`${
|
||||||
>
|
badge.achieved ? `bg-ielts-${badge.module}` : "bg-mti-gray-anti-flash"
|
||||||
|
} flex h-8 w-8 items-center justify-center rounded-full`}>
|
||||||
{badge.icon()}
|
{badge.icon()}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -157,21 +119,16 @@ export default function Navbar({
|
|||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1",
|
"border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1",
|
||||||
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white z-20"
|
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white z-20",
|
||||||
)}
|
)}
|
||||||
data-tip="Submit a help/feedback ticket"
|
data-tip="Submit a help/feedback ticket"
|
||||||
onClick={() => setIsTicketOpen(true)}
|
onClick={() => setIsTicketOpen(true)}>
|
||||||
>
|
|
||||||
<BsQuestionCircleFill />
|
<BsQuestionCircleFill />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showExpirationDate() && (
|
{showExpirationDate() && (
|
||||||
<Link
|
<Link
|
||||||
href={
|
href={!!user.subscriptionExpirationDate && !disablePaymentPage ? "/payment" : ""}
|
||||||
!!user.subscriptionExpirationDate && !disablePaymentPage
|
|
||||||
? "/payment"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
data-tip="Expiry date"
|
data-tip="Expiry date"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none",
|
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none",
|
||||||
@@ -179,40 +136,29 @@ export default function Navbar({
|
|||||||
!user.subscriptionExpirationDate
|
!user.subscriptionExpirationDate
|
||||||
? "bg-mti-green-ultralight border-mti-green-light"
|
? "bg-mti-green-ultralight border-mti-green-light"
|
||||||
: expirationDateColor(user.subscriptionExpirationDate),
|
: expirationDateColor(user.subscriptionExpirationDate),
|
||||||
"border-mti-gray-platinum bg-white"
|
"border-mti-gray-platinum bg-white",
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
{!user.subscriptionExpirationDate && "Unlimited"}
|
{!user.subscriptionExpirationDate && "Unlimited"}
|
||||||
{user.subscriptionExpirationDate &&
|
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
||||||
moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<Link
|
<Link href={disableNavigation ? "" : "/profile"} className="-md:hidden flex items-center justify-end gap-6">
|
||||||
href={disableNavigation ? "" : "/profile"}
|
<img src={user.profilePicture} alt={user.name} className="h-10 w-10 rounded-full object-cover" />
|
||||||
className="-md:hidden flex items-center justify-end gap-6"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={user.profilePicture}
|
|
||||||
alt={user.name}
|
|
||||||
className="h-10 w-10 rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
<span className="-md:hidden text-right">
|
<span className="-md:hidden text-right">
|
||||||
{user.type === "corporate"
|
{(user.type === "corporate" || user.type === "mastercorporate") && !!user.corporateInformation?.companyInformation?.name
|
||||||
? `${user.corporateInformation?.companyInformation.name} |`
|
? `${user.corporateInformation?.companyInformation.name} |`
|
||||||
: ""}{" "}
|
: ""}{" "}
|
||||||
{user.name} | {USER_TYPE_LABELS[user.type]}
|
{user.name} | {USER_TYPE_LABELS[user.type]}
|
||||||
|
{user.type === "corporate" &&
|
||||||
|
!!user.demographicInformation?.position &&
|
||||||
|
` | ${user.demographicInformation?.position || "N/A"}`}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div
|
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
|
||||||
className="cursor-pointer md:hidden"
|
|
||||||
onClick={() => setIsMenuOpen(true)}
|
|
||||||
>
|
|
||||||
<BsList className="text-mti-purple-light h-8 w-8" />
|
<BsList className="text-mti-purple-light h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{focusMode && (
|
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||||
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
|
|
||||||
)}
|
|
||||||
</header>
|
</header>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import React from "react";
|
||||||
import {Permission} from "@/interfaces/permissions";
|
import {Permission} from "@/interfaces/permissions";
|
||||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable, Row} from "@tanstack/react-table";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {convertCamelCaseToReadable} from "@/utils/string";
|
import {convertCamelCaseToReadable} from "@/utils/string";
|
||||||
|
|
||||||
@@ -29,8 +30,18 @@ export default function PermissionList({permissions}: Props) {
|
|||||||
columns: defaultColumns,
|
columns: defaultColumns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const groupedData: {[key: string]: Row<Permission>[]} = table.getRowModel().rows.reduce((groups: {[key: string]: Row<Permission>[]}, row) => {
|
||||||
|
const parent = row.original.topic;
|
||||||
|
if (!groups[parent]) {
|
||||||
|
groups[parent] = [];
|
||||||
|
}
|
||||||
|
groups[parent].push(row);
|
||||||
|
return groups;
|
||||||
|
}, {});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full h-full">
|
||||||
<div className="w-full flex flex-col gap-2">
|
<div className="w-full flex flex-col gap-2">
|
||||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -45,7 +56,14 @@ export default function PermissionList({permissions}: Props) {
|
|||||||
))}
|
))}
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="px-2">
|
<tbody className="px-2">
|
||||||
{table.getRowModel().rows.map((row) => (
|
{Object.keys(groupedData).map((parent) => (
|
||||||
|
<React.Fragment key={parent}>
|
||||||
|
<tr>
|
||||||
|
<td className="px-2 py-2 items-center w-fit">
|
||||||
|
<strong>{parent}</strong>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{groupedData[parent].map((row, i) => (
|
||||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
<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) => (
|
||||||
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
|
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
|
||||||
@@ -54,6 +72,8 @@ export default function PermissionList({permissions}: Props) {
|
|||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
121
src/components/QuestionsModal.tsx
Normal file
121
src/components/QuestionsModal.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
|
import { Fragment, useEffect, useState } from "react";
|
||||||
|
import Button from "./Low/Button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
type?: "module" | "blankQuestions" | "submit";
|
||||||
|
unanswered?: boolean;
|
||||||
|
onClose: (next?: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QuestionsModal({ isOpen, onClose, type = "module", unanswered = false }: Props) {
|
||||||
|
const [isClosing, setIsClosing] = useState(false);
|
||||||
|
|
||||||
|
const blockMultipleClicksClose = (x: boolean) => {
|
||||||
|
if (!isClosing) {
|
||||||
|
setIsClosing(true);
|
||||||
|
onClose(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsClosing(false);
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition show={isOpen} as={Fragment}>
|
||||||
|
<Dialog onClose={() => onClose(false)} className="relative z-50">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0">
|
||||||
|
<div className="fixed inset-0 bg-black/30" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95">
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
|
||||||
|
{type === "module" && (
|
||||||
|
<>
|
||||||
|
<Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title>
|
||||||
|
<span>
|
||||||
|
Please note that you are finishing the current module and once you proceed to the next module, you will no longer be
|
||||||
|
able to change the answers of the current one, including your unanswered questions. <br />
|
||||||
|
<br />
|
||||||
|
Are you sure you want to continue without completing those questions?
|
||||||
|
</span>
|
||||||
|
<div className="w-full flex justify-between mt-8">
|
||||||
|
<Button color="purple" onClick={() => blockMultipleClicksClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" onClick={() => blockMultipleClicksClose(true)} className="max-w-[200px] self-end w-full">
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{type === "blankQuestions" && (
|
||||||
|
<>
|
||||||
|
<Dialog.Title className="font-bold text-2xl">Questions Unanswered</Dialog.Title>
|
||||||
|
<div className="flex flex-col text-xl gap-2">
|
||||||
|
<p>You have left some questions unanswered in the current part.</p>
|
||||||
|
<p>If you wish to continue, you can still access this part later using the navigation bar at the top or the "Back" button.</p>
|
||||||
|
<p>Do you want to proceed to the next part, or would you like to go back and complete the unanswered questions in the current part?</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex justify-between mt-4">
|
||||||
|
<Button color="purple" onClick={() => blockMultipleClicksClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" onClick={() => blockMultipleClicksClose(true)} className="max-w-[200px] self-end w-full">
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{type === "submit" && (
|
||||||
|
<>
|
||||||
|
<Dialog.Title className="font-bold text-3xl text-mti-rose-light">Confirm Submission</Dialog.Title>
|
||||||
|
<span className="text-xl">
|
||||||
|
{unanswered ? (
|
||||||
|
<>
|
||||||
|
By clicking "Submit", you are finalizing your exam with some <b>questions left unanswered</b>. Once you submit, you will not be able to review or change any of your answers, including the unanswered ones. <br />
|
||||||
|
<br />
|
||||||
|
Are you sure you want to submit and complete the exam <b>with unanswered questions</b>?
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
By clicking "Submit", you are finalizing your exam. Once you submit, you will not be able to review or change any of your answers. <br />
|
||||||
|
<br />
|
||||||
|
Are you sure you want to submit and complete the exam?
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="w-full flex justify-between mt-4">
|
||||||
|
<Button color="purple" onClick={() => blockMultipleClicksClose(false)} variant="outline" className="max-w-[200px] self-end w-full !text-xl">
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
<Button color="rose" onClick={() => blockMultipleClicksClose(true)} className="max-w-[200px] self-end w-full !text-xl">
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</div>
|
||||||
|
</Transition.Child>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
BsCurrencyDollar,
|
BsCurrencyDollar,
|
||||||
BsClipboardData,
|
BsClipboardData,
|
||||||
BsFileLock,
|
BsFileLock,
|
||||||
|
BsPeople,
|
||||||
} from "react-icons/bs";
|
} from "react-icons/bs";
|
||||||
import {CiDumbbell} from "react-icons/ci";
|
import {CiDumbbell} from "react-icons/ci";
|
||||||
import {RiLogoutBoxFill} from "react-icons/ri";
|
import {RiLogoutBoxFill} from "react-icons/ri";
|
||||||
@@ -28,6 +29,7 @@ import usePreferencesStore from "@/stores/preferencesStore";
|
|||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import useTicketsListener from "@/hooks/useTicketsListener";
|
import useTicketsListener from "@/hooks/useTicketsListener";
|
||||||
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
||||||
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
interface Props {
|
interface Props {
|
||||||
path: string;
|
path: string;
|
||||||
navDisabled?: boolean;
|
navDisabled?: boolean;
|
||||||
@@ -80,6 +82,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
|
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
|
||||||
|
|
||||||
const {totalAssignedTickets} = useTicketsListener(user.id);
|
const {totalAssignedTickets} = useTicketsListener(user.id);
|
||||||
|
const {permissions} = usePermissions(user.id);
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
axios.post("/api/logout").finally(() => {
|
axios.post("/api/logout").finally(() => {
|
||||||
@@ -98,22 +101,25 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
)}>
|
)}>
|
||||||
<div className="-xl:hidden flex-col gap-3 xl:flex">
|
<div className="-xl:hidden flex-col gap-3 xl:flex">
|
||||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
|
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
|
||||||
{checkAccess(user, ["student", "teacher", "developer"], "viewExams") && (
|
{checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExams") && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={isMinimized} />
|
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={isMinimized} />
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, ["student", "teacher", "developer"], "viewExercises") && (
|
{checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExercises") && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={isMinimized} />
|
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={isMinimized} />
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, getTypesOfUser(["agent"]), "viewStats") && (
|
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
|
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && (
|
{checkAccess(user, ["developer", "admin", "teacher", "student"], permissions) && (
|
||||||
|
<Nav disabled={disableNavigation} Icon={BsPeople} label="Groups" path={path} keyPath="/groups" isMinimized={isMinimized} />
|
||||||
|
)}
|
||||||
|
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
|
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && (
|
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
|
||||||
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={isMinimized} />
|
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={isMinimized} />
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"], "viewPaymentRecords") && (
|
{checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"], permissions, "viewPaymentRecords") && (
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
Icon={BsCurrencyDollar}
|
Icon={BsCurrencyDollar}
|
||||||
@@ -133,7 +139,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
isMinimized={isMinimized}
|
isMinimized={isMinimized}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, ["admin", "developer", "agent"], "viewTickets") && (
|
{checkAccess(user, ["admin", "developer", "agent"], permissions, "viewTickets") && (
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
Icon={BsClipboardData}
|
Icon={BsClipboardData}
|
||||||
@@ -144,8 +150,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
badge={totalAssignedTickets}
|
badge={totalAssignedTickets}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, ["developer", "admin"]) && (
|
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
||||||
<>
|
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
Icon={BsCloudFill}
|
Icon={BsCloudFill}
|
||||||
@@ -154,6 +159,8 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
keyPath="/generation"
|
keyPath="/generation"
|
||||||
isMinimized={isMinimized}
|
isMinimized={isMinimized}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate", "agent"]) && (
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
Icon={BsFileLock}
|
Icon={BsFileLock}
|
||||||
@@ -162,20 +169,19 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
keyPath="/permissions"
|
keyPath="/permissions"
|
||||||
isMinimized={isMinimized}
|
isMinimized={isMinimized}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
||||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
|
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
|
||||||
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
|
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
|
||||||
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
|
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
|
||||||
{checkAccess(user, getTypesOfUser(["agent"]), "viewStats") && (
|
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
|
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && (
|
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
|
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && (
|
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
|
||||||
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={true} />
|
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={true} />
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, getTypesOfUser(["student"])) && (
|
{checkAccess(user, getTypesOfUser(["student"])) && (
|
||||||
@@ -206,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,54 +1,81 @@
|
|||||||
import {FillBlanksExercise} from "@/interfaces/exam";
|
import {FillBlanksExercise, FillBlanksMCOption, ShuffleMap} from "@/interfaces/exam";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import reactStringReplace from "react-string-replace";
|
import reactStringReplace from "react-string-replace";
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import {Fragment} from "react";
|
import {Fragment} from "react";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
|
||||||
|
export default function FillBlanksSolutions({id, type, prompt, solutions, words, text, onNext, onBack}: FillBlanksExercise & CommonProps) {
|
||||||
|
const storeUserSolutions = useExamStore((state) => state.userSolutions);
|
||||||
|
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
|
||||||
|
|
||||||
|
const correctUserSolutions = storeUserSolutions.find((solution) => solution.exercise === id)?.solutions;
|
||||||
|
|
||||||
|
const shuffles = useExamStore((state) => state.shuffles);
|
||||||
|
|
||||||
export default function FillBlanksSolutions({
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
prompt,
|
|
||||||
solutions,
|
|
||||||
words,
|
|
||||||
text,
|
|
||||||
userSolutions,
|
|
||||||
onNext,
|
|
||||||
onBack,
|
|
||||||
}: FillBlanksExercise & CommonProps) {
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||||
const correct = userSolutions.filter((x) => {
|
const correct = correctUserSolutions!.filter((x) => {
|
||||||
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution.toLowerCase();
|
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
|
||||||
if (!solution) return false;
|
if (!solution) return false;
|
||||||
|
|
||||||
const option = words.find((w) =>
|
const option = words.find((w) => {
|
||||||
typeof w === "string" ? w.toLowerCase() === x.solution.toLowerCase() : w.letter.toLowerCase() === x.solution.toLowerCase(),
|
if (typeof w === "string") {
|
||||||
);
|
return w.toLowerCase() === x.solution.toLowerCase();
|
||||||
|
} else if ("letter" in w) {
|
||||||
|
return w.letter.toLowerCase() === x.solution.toLowerCase();
|
||||||
|
} else {
|
||||||
|
return w.id.toString() === x.id.toString();
|
||||||
|
}
|
||||||
|
});
|
||||||
if (!option) return false;
|
if (!option) return false;
|
||||||
|
|
||||||
return solution === (typeof option === "string" ? option.toLowerCase() : option.word.toLowerCase());
|
if (typeof option === "string") {
|
||||||
|
return solution.toLowerCase() === option.toLowerCase();
|
||||||
|
} else if ("letter" in option) {
|
||||||
|
return solution.toLowerCase() === option.word.toLowerCase();
|
||||||
|
} else if ("options" in option) {
|
||||||
|
return option.options[solution as keyof typeof option.options] == x.solution;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}).length;
|
}).length;
|
||||||
const missing = total - userSolutions.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[] => {
|
||||||
|
return Array.isArray(words) && words.every((word) => word && typeof word === "object" && "id" in word && "options" in word);
|
||||||
|
};
|
||||||
|
|
||||||
const renderLines = (line: string) => {
|
const renderLines = (line: string) => {
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||||
const id = match.replaceAll(/[\{\}]/g, "");
|
const questionId = match.replaceAll(/[\{\}]/g, "");
|
||||||
const userSolution = userSolutions.find((x) => x.id === id);
|
const userSolution = correctUserSolutions!.find((x) => x.id.toString() === questionId.toString());
|
||||||
const solution = solutions.find((x) => x.id === id)!;
|
const answerSolution = solutions.find((sol) => sol.id.toString() === questionId.toString())!.solution;
|
||||||
|
const questionShuffleMap = shuffles.find((x) => x.exerciseID == id)?.shuffles.find((y) => y.questionID == questionId);
|
||||||
|
const newAnswerSolution = questionShuffleMap
|
||||||
|
? questionShuffleMap.map[answerSolution].toLowerCase()
|
||||||
|
: answerSolution.toLowerCase();
|
||||||
|
|
||||||
if (!userSolution) {
|
if (!userSolution) {
|
||||||
|
let answerText;
|
||||||
|
if (typeCheckWordsMC(words)) {
|
||||||
|
const options = words.find((x) => x.id.toString() === questionId.toString());
|
||||||
|
const correctKey = Object.keys(options!.options).find((key) => key.toLowerCase() === newAnswerSolution);
|
||||||
|
answerText = options!.options[correctKey as keyof typeof options];
|
||||||
|
} else {
|
||||||
|
answerText = answerSolution;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"rounded-full hover:text-white hover:bg-mti-gray-davy transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-gray-davy",
|
"rounded-full hover:text-white hover:bg-mti-gray-davy transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-gray-davy",
|
||||||
)}>
|
)}>
|
||||||
{solution?.solution}
|
{answerText}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -56,23 +83,50 @@ 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()
|
||||||
: w.letter.toLowerCase() === userSolution.solution.toLowerCase(),
|
: "letter" in w
|
||||||
|
? w.letter.toLowerCase() === userSolution.solution.toLowerCase()
|
||||||
|
: "options" in w
|
||||||
|
? w.id === userSolution.questionId
|
||||||
|
: false,
|
||||||
);
|
);
|
||||||
const userSolutionText = typeof userSolutionWord === "string" ? userSolutionWord : userSolutionWord?.word;
|
|
||||||
|
|
||||||
if (userSolutionText === solution.solution) {
|
const userSolutionText =
|
||||||
|
typeof userSolutionWord === "string"
|
||||||
|
? userSolutionWord
|
||||||
|
: userSolutionWord && "letter" in userSolutionWord
|
||||||
|
? userSolutionWord.word
|
||||||
|
: userSolutionWord && "options" in userSolutionWord
|
||||||
|
? userSolution.solution
|
||||||
|
: userSolution.solution;
|
||||||
|
|
||||||
|
let correct;
|
||||||
|
let solutionText;
|
||||||
|
if (typeCheckWordsMC(words)) {
|
||||||
|
const options = words.find((x) => x.id.toString() === questionId.toString());
|
||||||
|
if (options) {
|
||||||
|
const correctKey = Object.keys(options.options).find((key) => key.toLowerCase() === newAnswerSolution);
|
||||||
|
correct = userSolution.solution == options.options[correctKey as keyof typeof options.options];
|
||||||
|
solutionText = options.options[correctKey as keyof typeof options.options] || answerSolution;
|
||||||
|
} else {
|
||||||
|
correct = false;
|
||||||
|
solutionText = answerSolution;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
correct = userSolutionText === answerSolution;
|
||||||
|
solutionText = answerSolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (correct) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
|
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
|
||||||
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
|
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
|
||||||
)}>
|
)}>
|
||||||
{solution.solution}
|
{solutionText}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
} else {
|
||||||
|
|
||||||
if (userSolutionText !== solution.solution) {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -88,7 +142,7 @@ export default function FillBlanksSolutions({
|
|||||||
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
|
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
|
||||||
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
|
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
|
||||||
)}>
|
)}>
|
||||||
{solution.solution}
|
{solutionText}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -99,18 +153,28 @@ 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="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">
|
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
||||||
{userSolutions &&
|
{correctUserSolutions &&
|
||||||
text.split("\\n").map((line, index) => (
|
text.split("\\n").map((line, index) => (
|
||||||
<p key={index}>
|
<p key={index}>
|
||||||
{renderLines(line)}
|
{renderLines(line)}
|
||||||
@@ -138,18 +202,19 @@ export default function FillBlanksSolutions({
|
|||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
onClick={() => onBack({exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type})}
|
||||||
className="max-w-[200px] w-full">
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
onClick={() => onNext({exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type})}
|
||||||
className="max-w-[200px] self-end w-full">
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ 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} = {
|
||||||
|
"Grammatical Range and Accuracy":
|
||||||
|
"Assesses the variety and correctness of grammatical structures used. A higher score indicates a wide range of complex and accurate grammar; a lower score suggests the need for more basic grammar practice.",
|
||||||
|
"Fluency and Coherence":
|
||||||
|
"Evaluates smoothness and logical flow of speech. A higher score means natural, effortless speech and clear idea progression; a lower score indicates frequent pauses and difficulty in maintaining coherence.",
|
||||||
|
Pronunciation:
|
||||||
|
"Measures clarity and accuracy of spoken words. A higher score reflects clear, well-articulated speech with correct intonation; a lower score shows challenges in being understood.",
|
||||||
|
"Lexical Resource":
|
||||||
|
"Looks at the range and appropriateness of vocabulary. A higher score demonstrates a rich and precise vocabulary; a lower score suggests limited vocabulary usage and appropriateness.",
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
|
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
|
||||||
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(
|
||||||
@@ -44,7 +55,41 @@ export default function InteractiveSpeaking({
|
|||||||
}, [userSolutions]);
|
}, [userSolutions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||||
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
onBack({
|
||||||
|
exercise: id,
|
||||||
|
solutions: userSolutions,
|
||||||
|
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() =>
|
||||||
|
onNext({
|
||||||
|
exercise: id,
|
||||||
|
solutions: userSolutions,
|
||||||
|
score: {
|
||||||
|
total: 100,
|
||||||
|
missing: 0,
|
||||||
|
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
||||||
|
},
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Modal title={`Correction (Prompt ${diffNumber})`} isOpen={diffNumber !== 0} onClose={() => setDiffNumber(0)}>
|
<Modal title={`Correction (Prompt ${diffNumber})`} isOpen={diffNumber !== 0} onClose={() => setDiffNumber(0)}>
|
||||||
<>
|
<>
|
||||||
{userSolutions &&
|
{userSolutions &&
|
||||||
@@ -115,13 +160,13 @@ export default function InteractiveSpeaking({
|
|||||||
{userSolutions &&
|
{userSolutions &&
|
||||||
userSolutions.length > 0 &&
|
userSolutions.length > 0 &&
|
||||||
userSolutions[0].evaluation &&
|
userSolutions[0].evaluation &&
|
||||||
userSolutions[0].evaluation[`transcript_${(index + 1)}`] &&
|
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>
|
||||||
)}
|
)}
|
||||||
@@ -132,12 +177,18 @@ export default function InteractiveSpeaking({
|
|||||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<div className="flex gap-4 px-1">
|
<div className="flex gap-4 px-1">
|
||||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key, index) => {
|
||||||
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
||||||
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")} key={key}>
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
|
||||||
|
index === 0 && "tooltip-right",
|
||||||
|
)}
|
||||||
|
key={key}
|
||||||
|
data-tip={tooltips[key] || "No additional information available"}>
|
||||||
{key}: Level {grade}
|
{key}: Level {grade}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -169,7 +220,9 @@ export default function InteractiveSpeaking({
|
|||||||
}>
|
}>
|
||||||
Evaluation
|
Evaluation
|
||||||
</Tab>
|
</Tab>
|
||||||
{Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).map((key, index) => (
|
{Object.keys(userSolutions[0].evaluation)
|
||||||
|
.filter((x) => x.startsWith("perfect_answer"))
|
||||||
|
.map((key, index) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={key}
|
key={key}
|
||||||
className={({selected}) =>
|
className={({selected}) =>
|
||||||
@@ -177,10 +230,14 @@ export default function InteractiveSpeaking({
|
|||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
selected
|
||||||
|
? "bg-white shadow"
|
||||||
|
: "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||||
)
|
)
|
||||||
}>
|
}>
|
||||||
Recommended Answer<br />(Prompt {index + 1})
|
Recommended Answer
|
||||||
|
<br />
|
||||||
|
(Prompt {index + 1})
|
||||||
</Tab>
|
</Tab>
|
||||||
))}
|
))}
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
@@ -193,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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -205,10 +268,15 @@ export default function InteractiveSpeaking({
|
|||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
{Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).map((key, index) => (
|
{Object.keys(userSolutions[0].evaluation)
|
||||||
|
.filter((x) => x.startsWith("perfect_answer"))
|
||||||
|
.map((key, index) => (
|
||||||
<Tab.Panel key={key} className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel key={key} className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||||
{userSolutions[0].evaluation![`perfect_answer_${(index + 1)}`].answer.replaceAll(/\s{2,}/g, "\n\n")}
|
{userSolutions[0].evaluation![`perfect_answer_${index + 1}`].answer.replaceAll(
|
||||||
|
/\s{2,}/g,
|
||||||
|
"\n\n",
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
))}
|
))}
|
||||||
@@ -257,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} 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,
|
||||||
@@ -15,56 +15,73 @@ function Question({
|
|||||||
options,
|
options,
|
||||||
userSolution,
|
userSolution,
|
||||||
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
||||||
|
const {userSolutions} = useExamStore((state) => state);
|
||||||
|
|
||||||
|
const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => {
|
||||||
|
if (foundMap) return foundMap;
|
||||||
|
return userSolution.shuffleMaps?.find((map) => map.questionID === id) || null;
|
||||||
|
}, null as ShuffleMap | null);
|
||||||
|
|
||||||
|
const newSolution = questionShuffleMap ? questionShuffleMap?.map[solution] : solution;
|
||||||
|
|
||||||
const renderPrompt = (prompt: string) => {
|
const renderPrompt = (prompt: string) => {
|
||||||
return reactStringReplace(prompt, /((<u>)[\w\s']+(<\/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>{word}</u> : null;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const optionColor = (option: string) => {
|
const optionColor = (option: string) => {
|
||||||
if (option === solution && !userSolution) {
|
if (option === newSolution && !userSolution) {
|
||||||
return "!border-mti-gray-davy !text-mti-gray-davy";
|
return "!bg-mti-gray-davy !text-white";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (option === solution) {
|
if (option === newSolution) {
|
||||||
return "!border-mti-purple-light !text-mti-purple-light";
|
return "!bg-mti-purple-light !text-white";
|
||||||
}
|
}
|
||||||
|
|
||||||
return userSolution === option ? "!border-mti-rose-light !text-mti-rose-light" : "";
|
return userSolution === option ? "!bg-mti-rose-light !text-white" : "";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{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="">
|
<span className="text-lg" key={v4()}>
|
||||||
<>
|
<>
|
||||||
{id} - <span>{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="grid grid-cols-4 gap-4 place-items-center">
|
<div className="flex flex-wrap gap-4 justify-between">
|
||||||
{variant === "image" &&
|
{variant === "image" &&
|
||||||
options.map((option) => (
|
options.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={option.id}
|
key={option?.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative",
|
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative select-none",
|
||||||
optionColor(option.id),
|
optionColor(option!.id),
|
||||||
)}>
|
)}>
|
||||||
<span className={clsx("text-sm", solution !== option.id && userSolution !== option.id && "opacity-50")}>{option.id}</span>
|
<span className={clsx("text-sm", newSolution !== option?.id && userSolution !== option?.id && "opacity-50")}>
|
||||||
<img src={option.src!} alt={`Option ${option.id}`} />
|
{option?.id}
|
||||||
|
</span>
|
||||||
|
{"src" in option && <img src={option?.src!} alt={`Option ${option?.id}`} />}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{variant === "text" &&
|
{variant === "text" &&
|
||||||
options.map((option) => (
|
options.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={option.id}
|
key={option?.id}
|
||||||
className={clsx("flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm", optionColor(option.id))}>
|
className={clsx(
|
||||||
<span className="font-semibold">{option.id}.</span>
|
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none",
|
||||||
<span>{option.text}</span>
|
optionColor(option!.id),
|
||||||
|
)}>
|
||||||
|
<span className="font-semibold">{option?.id}.</span>
|
||||||
|
<span>{option?.text}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -73,23 +90,31 @@ 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} = 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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,15 +122,31 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
|
|||||||
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"
|
||||||
|
variant="outline"
|
||||||
|
onClick={back}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 w-full h-full mb-20 mt-4">
|
||||||
|
<div className="flex flex-col gap-4 mt-2">
|
||||||
|
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
|
{/*<span className="text-xl font-semibold">{prompt}</span>*/}
|
||||||
{userSolutions && questionIndex < questions.length && (
|
{userSolutions && questionIndex < questions.length && (
|
||||||
<Question
|
<Question
|
||||||
{...questions[questionIndex]}
|
{...questions[questionIndex]}
|
||||||
@@ -113,6 +154,17 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{userSolutions && questionIndex + 1 < questions.length && (
|
||||||
|
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
|
<Question
|
||||||
|
{...questions[questionIndex + 1]}
|
||||||
|
userSolution={userSolutions.find((x) => questions[questionIndex + 1].id === x.question)?.option}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-purple" />
|
<div className="w-4 h-4 rounded-full bg-mti-purple" />
|
||||||
@@ -130,7 +182,12 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full">
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={back}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -138,6 +195,6 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,53 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
}
|
}
|
||||||
}, [userSolutions]);
|
}, [userSolutions]);
|
||||||
|
|
||||||
|
const tooltips: {[key: string]: string} = {
|
||||||
|
"Grammatical Range and Accuracy":
|
||||||
|
"Assesses the variety and correctness of grammatical structures used. A higher score indicates a wide range of complex and accurate grammar; a lower score suggests the need for more basic grammar practice.",
|
||||||
|
"Fluency and Coherence":
|
||||||
|
"Evaluates smoothness and logical flow of speech. A higher score means natural, effortless speech and clear idea progression; a lower score indicates frequent pauses and difficulty in maintaining coherence.",
|
||||||
|
Pronunciation:
|
||||||
|
"Measures clarity and accuracy of spoken words. A higher score reflects clear, well-articulated speech with correct intonation; a lower score shows challenges in being understood.",
|
||||||
|
"Lexical Resource":
|
||||||
|
"Looks at the range and appropriateness of vocabulary. A higher score demonstrates a rich and precise vocabulary; a lower score suggests limited vocabulary usage and appropriateness.",
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||||
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
onBack({
|
||||||
|
exercise: id,
|
||||||
|
solutions: userSolutions,
|
||||||
|
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() =>
|
||||||
|
onNext({
|
||||||
|
exercise: id,
|
||||||
|
solutions: userSolutions,
|
||||||
|
score: {
|
||||||
|
total: 100,
|
||||||
|
missing: 0,
|
||||||
|
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
||||||
|
},
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}>
|
<Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}>
|
||||||
<>
|
<>
|
||||||
{userSolutions &&
|
{userSolutions &&
|
||||||
@@ -126,12 +171,18 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<div className="flex gap-4 px-1">
|
<div className="flex gap-4 px-1">
|
||||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key, index) => {
|
||||||
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
||||||
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")} key={key}>
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
|
||||||
|
index === 0 && "tooltip-right",
|
||||||
|
)}
|
||||||
|
key={key}
|
||||||
|
data-tip={tooltips[key] || "No additional information available"}>
|
||||||
{key}: Level {grade}
|
{key}: Level {grade}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -185,10 +236,16 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -252,6 +309,6 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import reactStringReplace from "react-string-replace";
|
|||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import {Fragment} from "react";
|
import {Fragment} from "react";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
|
||||||
type Solution = "true" | "false" | "not_given";
|
type Solution = "true" | "false" | "not_given";
|
||||||
|
|
||||||
export default function TrueFalseSolution({prompt, type, id, questions, userSolutions, onNext, onBack}: TrueFalseExercise & CommonProps) {
|
export default function TrueFalseSolution({prompt, type, id, questions, userSolutions, onNext, onBack}: TrueFalseExercise & CommonProps) {
|
||||||
|
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = questions.length || 0;
|
const total = questions.length || 0;
|
||||||
const correct = userSolutions.filter(
|
const correct = userSolutions.filter(
|
||||||
@@ -37,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,
|
||||||
@@ -49,15 +50,11 @@ function Blank({
|
|||||||
{userSolution && !isUserSolutionCorrect() && (
|
{userSolution && !isUserSolutionCorrect() && (
|
||||||
<div
|
<div
|
||||||
className="py-2 px-3 rounded-2xl w-fit focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light"
|
className="py-2 px-3 rounded-2xl w-fit focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light"
|
||||||
placeholder={id}
|
|
||||||
contentEditable={disabled}>
|
contentEditable={disabled}>
|
||||||
{userSolution}
|
{userSolution}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())} contentEditable={disabled}>
|
||||||
className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())}
|
|
||||||
placeholder={id}
|
|
||||||
contentEditable={disabled}>
|
|
||||||
{!solutions ? userInput : solutions.join(" / ")}
|
{!solutions ? userInput : solutions.join(" / ")}
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
@@ -75,6 +72,8 @@ export default function WriteBlanksSolutions({
|
|||||||
onNext,
|
onNext,
|
||||||
onBack,
|
onBack,
|
||||||
}: WriteBlanksExercise & CommonProps) {
|
}: WriteBlanksExercise & CommonProps) {
|
||||||
|
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||||
const correct = userSolutions.filter(
|
const correct = userSolutions.filter(
|
||||||
@@ -106,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) => (
|
||||||
@@ -146,7 +163,8 @@ export default function WriteBlanksSolutions({
|
|||||||
color="purple"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
className="max-w-[200px] w-full">
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -157,6 +175,6 @@ export default function WriteBlanksSolutions({
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,53 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
|
|
||||||
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} = {
|
||||||
|
"Lexical Resource":
|
||||||
|
"Assesses the diversity and accuracy of vocabulary used. A higher score indicates varied and precise word choice; a lower score points to limited vocabulary and inaccuracies.",
|
||||||
|
"Task Achievement":
|
||||||
|
"Evaluates how well the task requirements are fulfilled. A higher score means all parts of the task are addressed thoroughly; a lower score shows incomplete or inadequate task response.",
|
||||||
|
"Coherence and Cohesion":
|
||||||
|
"Measures logical organization and flow of writing. A higher score reflects well-structured and connected ideas; a lower score indicates disorganized writing and poor linkage between ideas.",
|
||||||
|
"Grammatical Range and Accuracy":
|
||||||
|
"Looks at the range and precision of grammatical structures. A higher score shows varied and accurate grammar use; a lower score suggests frequent errors and limited range.",
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
onBack({
|
||||||
|
exercise: id,
|
||||||
|
solutions: userSolutions,
|
||||||
|
score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() =>
|
||||||
|
onNext({
|
||||||
|
exercise: id,
|
||||||
|
solutions: userSolutions,
|
||||||
|
score: {
|
||||||
|
total: 100,
|
||||||
|
missing: 0,
|
||||||
|
correct: userSolutions[0]?.evaluation ? writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
||||||
|
},
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{attachment && (
|
{attachment && (
|
||||||
<Transition show={isModalOpen} as={Fragment}>
|
<Transition show={isModalOpen} as={Fragment}>
|
||||||
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
||||||
@@ -123,12 +168,18 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<div className="flex gap-4 px-1">
|
<div className="flex gap-4 px-1">
|
||||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key, index) => {
|
||||||
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
||||||
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-writing text-ielts-writing-light rounded-xl px-4 py-2")} key={key}>
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
|
||||||
|
index === 0 && "tooltip-right",
|
||||||
|
)}
|
||||||
|
key={key}
|
||||||
|
data-tip={tooltips[key] || "No additional information available"}>
|
||||||
{key}: Level {grade}
|
{key}: Level {grade}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -194,10 +245,16 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -263,6 +320,6 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
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,23 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
|
|
||||||
const HighlightedContent: React.FC<{ html: string; highlightPhrases: string[] }> = ({ html, highlightPhrases }) => {
|
|
||||||
|
|
||||||
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('|')})`, 'gi');
|
|
||||||
const highlightedHtml = html.replace(regex, (match) => `<span style="background-color: yellow;">${match}</span>`);
|
|
||||||
|
|
||||||
return { __html: highlightedHtml };
|
|
||||||
}, [html, highlightPhrases]);
|
|
||||||
|
|
||||||
return <div dangerouslySetInnerHTML={createHighlightedContent()} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HighlightedContent;
|
|
||||||
@@ -1,90 +1,42 @@
|
|||||||
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: "Strategy",
|
"category": "",
|
||||||
body: "<p>Look for <b>clues to the main idea</b> in the first (and sometimes second) sentence of a paragraph.</p>"
|
"embedding": "",
|
||||||
|
"text": "",
|
||||||
|
"html": "",
|
||||||
|
"id": "",
|
||||||
|
"verified": true,
|
||||||
|
"standalone": false,
|
||||||
|
"exercise": {
|
||||||
|
"question": "",
|
||||||
|
"additional": "",
|
||||||
|
"segments": []
|
||||||
}
|
}
|
||||||
const question = "<div class=\"container mx-auto px-4 py-8\"><h2 class=\"text-2xl font-bold mb-4\">Identifying Main Ideas</h2><p class=\"text-lg leading-relaxed mb-6\">Read the statements below. Circle the main idea in each pair of statements (a or b).</p></div>";
|
|
||||||
const rightTextData: WalkthroughConfigs[] = [
|
|
||||||
{
|
|
||||||
"html": "<div class='bg-blue-100 p-4 rounded-lg mb-4'><h2 class='text-xl font-bold mb-2'>Identifying Main Ideas</h2><p class='text-gray-700 leading-relaxed'>Let's analyze each pair of statements to determine which one represents the main idea. We'll focus on which statement is more general and encompasses the overall concept.</p></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 5000,
|
|
||||||
"highlight": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"html": "<div class='bg-green-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>1. Self</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You need to take care of yourself and connect with the people around you.</b></p><p class='mt-2'>This statement is more comprehensive, covering both self-care and social connections. Option B is more specific and could be considered a subset of A.</p></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 8000,
|
|
||||||
"highlight": ["You need to take care of yourself and connect with the people around you."]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"html": "<div class='bg-yellow-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>2. Home</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>B. You should arrange your home so that it makes you feel happy.</b></p><p class='mt-2'>This statement is more general and applies to the entire home. Option A is a specific example that could fall under this broader concept.</p></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 8000,
|
|
||||||
"highlight": ["You should arrange your home so that it makes you feel happy."]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"html": "<div class='bg-pink-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>3. Financial Life</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You can be happy if you have enough money, but don't want money too much.</b></p><p class='mt-2'>This statement provides a balanced view of money's role in happiness. Option B is more specific and could be seen as a consequence of wanting money too much.</p></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 8000,
|
|
||||||
"highlight": ["You can be happy if you have enough money, but don't want money too much."]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"html": "<div class='bg-purple-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>4. Social Life</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. A good group of friends can increase your happiness.</b></p><p class='mt-2'>This statement is more general about the impact of friendships. Option B provides a specific statistic that supports this main idea.</p></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 8000,
|
|
||||||
"highlight": ["A good group of friends can increase your happiness."]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"html": "<div class='bg-indigo-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>5. Workplace</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You spend a lot of time at work, so you should like your workplace.</b></p><p class='mt-2'>This statement covers the overall importance of workplace satisfaction. Option B focuses on one specific aspect (the boss) and is less comprehensive.</p></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 8000,
|
|
||||||
"highlight": ["You spend a lot of time at work, so you should like your workplace."]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"html": "<div class='bg-red-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>6. Community</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. The place where you live is more important for happiness than anything else.</b></p><p class='mt-2'>While this statement might be debatable, it's more general and encompasses the overall importance of community. Option B is a specific suggestion about community demographics.</p></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 8000,
|
|
||||||
"highlight": ["The place where you live is more important for happiness than anything else."]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"html": "<div class='bg-orange-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>Key Strategy</h3><p class='text-gray-700 leading-relaxed'>When identifying main ideas:</p><ul class='list-disc pl-5 space-y-2'><li>Look for broader, more encompassing statements</li><li>Consider which statement other ideas could fall under</li><li>Identify which statement provides a general principle rather than a specific example</li></ul></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 8000,
|
|
||||||
"highlight": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"html": "<div class='bg-teal-50 p-4 rounded-lg'><h3 class='text-lg font-semibold mb-2'>Helpful Tip</h3><p class='text-gray-700 leading-relaxed'>Remember to look for clues to the main idea in the first (and sometimes second) sentence of a paragraph. In this exercise, we applied this concept to pairs of statements. This approach can help you quickly identify the central theme or main point in various types of text.</p></div>",
|
|
||||||
"wordDelay": 200,
|
|
||||||
"holdDelay": 5000,
|
|
||||||
"highlight": []
|
|
||||||
}
|
}
|
||||||
]
|
|
||||||
|
|
||||||
const mockTip: ITrainingTip = {
|
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[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formattedTip = formatTip(mockTip);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col p-10">
|
<ExerciseWalkthrough {...formatTip(trainingTip)}
|
||||||
<ExerciseWalkthrough {...trainingTip}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +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 HighlightedContent from './AnimatedHighlight';
|
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()) {
|
||||||
@@ -22,6 +34,7 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
|||||||
}
|
}
|
||||||
return !prev;
|
return !prev;
|
||||||
});
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [currentTime]);
|
}, [currentTime]);
|
||||||
|
|
||||||
const handleAnimationComplete = useCallback(() => {
|
const handleAnimationComplete = useCallback(() => {
|
||||||
@@ -42,6 +55,7 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
|||||||
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();
|
||||||
@@ -82,18 +96,66 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
|||||||
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(
|
const currentEvents = timelineRef.current.filter(
|
||||||
event => currentTime >= event.start && currentTime < event.end
|
event => currentTime >= event.start && currentTime <= event.end
|
||||||
);
|
);
|
||||||
|
|
||||||
if (currentEvent) {
|
if (currentTime < lastProcessedInsertTime.current) {
|
||||||
|
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') {
|
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;
|
||||||
@@ -148,22 +210,63 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
|||||||
const newHtml = previousSegmentsHtml + currentSegmentHtml;
|
const newHtml = previousSegmentsHtml + currentSegmentHtml;
|
||||||
|
|
||||||
setWalkthroughHtml(newHtml);
|
setWalkthroughHtml(newHtml);
|
||||||
setHighlightedPhrases([]);
|
setCurrentSegmentIndex(currentEvent.segmentIndex);
|
||||||
|
setCurrentHighlightConfigs([]);
|
||||||
} else if (currentEvent.type === 'highlight') {
|
} 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];
|
||||||
@@ -218,25 +321,10 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (tip.standalone || !tip.exercise) {
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto">
|
<div className="container mx-auto py-6">
|
||||||
<h1 className='text-xl font-bold text-red-600'>The exercise for this tip is not available yet!</h1>
|
<Tip category={tip.tipCategory} html={tip.tipHtml} />
|
||||||
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4 mt-10">
|
{!tip.standalone && (
|
||||||
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
|
|
||||||
<div className="text-gray-700" dangerouslySetInnerHTML={{ __html: tip.tipHtml }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto">
|
|
||||||
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4">
|
|
||||||
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
|
|
||||||
<div className="text-gray-700" dangerouslySetInnerHTML={{ __html: tip.tipHtml }} />
|
|
||||||
</div>
|
|
||||||
<div className='flex flex-col space-y-4'>
|
<div className='flex flex-col space-y-4'>
|
||||||
<div className='flex flex-row items-center space-x-4 py-4'>
|
<div className='flex flex-row items-center space-x-4 py-4'>
|
||||||
<button
|
<button
|
||||||
@@ -263,24 +351,50 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
|||||||
className='flex-grow'
|
className='flex-grow'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4'>
|
<div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 w-full'>
|
||||||
<div className='flex-1 bg-white p-6 rounded-lg shadow'>
|
<div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 w-full'>
|
||||||
{/*<h2 className="text-xl font-bold mb-4">Question</h2>*/}
|
<div className='flex-1 bg-white p-6 rounded-lg shadow space-y-6'>
|
||||||
<div className="mb-4" dangerouslySetInnerHTML={{ __html: tip.exercise.question }} />
|
<div className="container mx-auto px-4">
|
||||||
<HighlightedContent html={tip.exercise.highlightable} highlightPhrases={highlightedPhrases} />
|
<div id="question-container" className="border p-6 rounded-lg shadow-md">
|
||||||
</div>
|
<HighlightContent
|
||||||
<div className='flex-1'>
|
html={questionHtml}
|
||||||
<div className='bg-gray-50 rounded-lg shadow'>
|
highlightConfigs={currentHighlightConfigs}
|
||||||
<div className='p-6 space-y-4'>
|
contentType="question"
|
||||||
<animated.div
|
|
||||||
dangerouslySetInnerHTML={{ __html: walkthroughHtml }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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,11 +1,5 @@
|
|||||||
import useStats from "@/hooks/useStats";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import {
|
import {CorporateInformation, CorporateUser, EMPLOYMENT_STATUS, User, Type, Stat, Gender} from "@/interfaces/user";
|
||||||
CorporateInformation,
|
|
||||||
CorporateUser,
|
|
||||||
EMPLOYMENT_STATUS,
|
|
||||||
User,
|
|
||||||
Type,
|
|
||||||
} 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";
|
||||||
@@ -14,13 +8,7 @@ import moment from "moment";
|
|||||||
import {Divider} from "primereact/divider";
|
import {Divider} from "primereact/divider";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import ReactDatePicker from "react-datepicker";
|
import ReactDatePicker from "react-datepicker";
|
||||||
import {
|
import {BsFileEarmarkText, BsPencil, BsPerson, BsPersonAdd, BsStar} from "react-icons/bs";
|
||||||
BsFileEarmarkText,
|
|
||||||
BsPencil,
|
|
||||||
BsPerson,
|
|
||||||
BsPersonAdd,
|
|
||||||
BsStar,
|
|
||||||
} from "react-icons/bs";
|
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import Button from "./Low/Button";
|
import Button from "./Low/Button";
|
||||||
import Checkbox from "./Low/Checkbox";
|
import Checkbox from "./Low/Checkbox";
|
||||||
@@ -35,17 +23,15 @@ import useCodes from "@/hooks/useCodes";
|
|||||||
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
||||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||||
import {PermissionType} from "@/interfaces/permissions";
|
import {PermissionType} from "@/interfaces/permissions";
|
||||||
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
|
||||||
const expirationDateColor = (date: Date) => {
|
const expirationDateColor = (date: Date) => {
|
||||||
const momentDate = moment(date);
|
const momentDate = moment(date);
|
||||||
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";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -55,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;
|
||||||
@@ -89,6 +76,7 @@ const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency }) => ({
|
|||||||
const UserCard = ({
|
const UserCard = ({
|
||||||
user,
|
user,
|
||||||
loggedInUser,
|
loggedInUser,
|
||||||
|
maxUserAmount,
|
||||||
onClose,
|
onClose,
|
||||||
onViewStudents,
|
onViewStudents,
|
||||||
onViewTeachers,
|
onViewTeachers,
|
||||||
@@ -96,71 +84,51 @@ const UserCard = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
disabledFields = {},
|
disabledFields = {},
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(
|
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
|
||||||
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>(
|
const [position, setPosition] = useState<string | undefined>(
|
||||||
user.type === "corporate"
|
user.type === "corporate" || user.type === "mastercorporate" ? user.demographicInformation?.position : undefined,
|
||||||
? user.demographicInformation?.position
|
|
||||||
: undefined
|
|
||||||
);
|
|
||||||
const [passport_id, setPassportID] = useState<string | undefined>(
|
|
||||||
user.type === "student"
|
|
||||||
? user.demographicInformation?.passport_id
|
|
||||||
: 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(
|
const [referralAgent, setReferralAgent] = useState(
|
||||||
user.type === "corporate"
|
user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation?.referralAgent : undefined,
|
||||||
? user.corporateInformation?.referralAgent
|
|
||||||
: undefined
|
|
||||||
);
|
);
|
||||||
const [companyName, setCompanyName] = useState(
|
const [companyName, setCompanyName] = useState(
|
||||||
user.type === "corporate"
|
user.type === "corporate" || user.type === "mastercorporate"
|
||||||
? user.corporateInformation?.companyInformation.name
|
? user.corporateInformation?.companyInformation.name
|
||||||
: user.type === "agent"
|
: user.type === "agent"
|
||||||
? user.agentInformation?.companyName
|
? user.agentInformation?.companyName
|
||||||
: undefined
|
: undefined,
|
||||||
);
|
|
||||||
const [arabName, setArabName] = useState(
|
|
||||||
user.type === "agent" ? user.agentInformation?.companyArabName : undefined
|
|
||||||
);
|
);
|
||||||
|
const [arabName, setArabName] = useState(user.type === "agent" ? user.agentInformation?.companyArabName : undefined);
|
||||||
const [commercialRegistration, setCommercialRegistration] = useState(
|
const [commercialRegistration, setCommercialRegistration] = useState(
|
||||||
user.type === "agent"
|
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
|
||||||
? user.agentInformation?.commercialRegistration
|
|
||||||
: undefined
|
|
||||||
);
|
);
|
||||||
const [userAmount, setUserAmount] = useState(
|
const [userAmount, setUserAmount] = useState(
|
||||||
user.type === "corporate"
|
user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation?.companyInformation.userAmount : undefined,
|
||||||
? user.corporateInformation?.companyInformation.userAmount
|
|
||||||
: undefined
|
|
||||||
);
|
);
|
||||||
const [paymentValue, setPaymentValue] = useState(
|
const [paymentValue, setPaymentValue] = useState(
|
||||||
user.type === "corporate"
|
user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation?.payment?.value : undefined,
|
||||||
? user.corporateInformation?.payment?.value
|
|
||||||
: undefined
|
|
||||||
);
|
);
|
||||||
const [paymentCurrency, setPaymentCurrency] = useState(
|
const [paymentCurrency, setPaymentCurrency] = useState(
|
||||||
user.type === "corporate"
|
user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation?.payment?.currency : "EUR",
|
||||||
? user.corporateInformation?.payment?.currency
|
|
||||||
: "EUR"
|
|
||||||
);
|
);
|
||||||
const [monthlyDuration, setMonthlyDuration] = useState(
|
const [monthlyDuration, setMonthlyDuration] = useState(
|
||||||
user.type === "corporate"
|
user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation?.monthlyDuration : undefined,
|
||||||
? user.corporateInformation?.monthlyDuration
|
|
||||||
: undefined
|
|
||||||
);
|
);
|
||||||
const [commissionValue, setCommission] = useState(
|
const [commissionValue, setCommission] = useState(
|
||||||
user.type === "corporate"
|
user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation?.payment?.commission : undefined,
|
||||||
? user.corporateInformation?.payment?.commission
|
|
||||||
: undefined
|
|
||||||
);
|
);
|
||||||
const { stats } = useStats(user.id);
|
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);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (users && users.length > 0) {
|
if (users && users.length > 0) {
|
||||||
@@ -175,19 +143,27 @@ const UserCard = ({
|
|||||||
}, [users, referralAgent]);
|
}, [users, referralAgent]);
|
||||||
|
|
||||||
const updateUser = () => {
|
const updateUser = () => {
|
||||||
if (user.type === "corporate" && (!paymentValue || paymentValue < 0))
|
if (
|
||||||
return toast.error(
|
(user.type === "corporate" || user.type === "mastercorporate") &&
|
||||||
"Please set a price for the user's package before updating!"
|
(!paymentValue || paymentValue < 0) &&
|
||||||
);
|
["admin", "developer"].includes(loggedInUser.type)
|
||||||
if (!confirm(`Are you sure you want to update ${user.name}'s account?`))
|
)
|
||||||
return;
|
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;
|
||||||
|
|
||||||
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"
|
||||||
? {
|
? {
|
||||||
@@ -197,7 +173,7 @@ const UserCard = ({
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
corporateInformation:
|
corporateInformation:
|
||||||
type === "corporate"
|
type === "corporate" || type === "mastercorporate"
|
||||||
? {
|
? {
|
||||||
referralAgent,
|
referralAgent,
|
||||||
monthlyDuration,
|
monthlyDuration,
|
||||||
@@ -208,9 +184,7 @@ const UserCard = ({
|
|||||||
payment: {
|
payment: {
|
||||||
value: paymentValue,
|
value: paymentValue,
|
||||||
currency: paymentCurrency,
|
currency: paymentCurrency,
|
||||||
...(referralAgent === ""
|
...(referralAgent === "" ? {} : {commission: commissionValue}),
|
||||||
? {}
|
|
||||||
: { commission: commissionValue }),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -226,9 +200,7 @@ const UserCard = ({
|
|||||||
|
|
||||||
const generalProfileItems = [
|
const generalProfileItems = [
|
||||||
{
|
{
|
||||||
icon: (
|
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||||
<BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
|
|
||||||
),
|
|
||||||
value: Object.keys(groupBySession(stats)).length,
|
value: Object.keys(groupBySession(stats)).length,
|
||||||
label: "Exams",
|
label: "Exams",
|
||||||
},
|
},
|
||||||
@@ -245,20 +217,16 @@ const UserCard = ({
|
|||||||
];
|
];
|
||||||
|
|
||||||
const corporateProfileItems =
|
const corporateProfileItems =
|
||||||
user.type === "corporate"
|
user.type === "corporate" || user.type === "mastercorporate"
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
icon: (
|
icon: <BsPerson className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||||
<BsPerson className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
|
|
||||||
),
|
|
||||||
value: codes.length,
|
value: codes.length,
|
||||||
label: "Users Used",
|
label: "Users Used",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: (
|
icon: <BsPersonAdd className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||||
<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",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -272,11 +240,7 @@ const UserCard = ({
|
|||||||
<>
|
<>
|
||||||
<ProfileSummary
|
<ProfileSummary
|
||||||
user={user}
|
user={user}
|
||||||
items={
|
items={user.type === "corporate" || user.type === "mastercorporate" ? corporateProfileItems : generalProfileItems}
|
||||||
user.type === "corporate"
|
|
||||||
? corporateProfileItems
|
|
||||||
: generalProfileItems
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{user.type === "agent" && (
|
{user.type === "agent" && (
|
||||||
@@ -316,7 +280,7 @@ const UserCard = ({
|
|||||||
<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
|
||||||
@@ -326,16 +290,31 @@ const UserCard = ({
|
|||||||
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"
|
||||||
@@ -344,12 +323,10 @@ const UserCard = ({
|
|||||||
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">
|
<label className="font-normal text-base text-mti-gray-dim">Pricing</label>
|
||||||
Pricing
|
|
||||||
</label>
|
|
||||||
<div className="w-full grid grid-cols-6 gap-2">
|
<div className="w-full grid grid-cols-6 gap-2">
|
||||||
<Input
|
<Input
|
||||||
name="paymentValue"
|
name="paymentValue"
|
||||||
@@ -357,18 +334,15 @@ const UserCard = ({
|
|||||||
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(
|
||||||
"px-4 py-4 col-span-3 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
|
"px-4 py-4 col-span-3 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
|
||||||
disabled &&
|
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
||||||
"!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed"
|
|
||||||
)}
|
)}
|
||||||
options={CURRENCIES_OPTIONS}
|
options={CURRENCIES_OPTIONS}
|
||||||
value={CURRENCIES_OPTIONS.find(
|
value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)}
|
||||||
(c) => c.value === paymentCurrency
|
|
||||||
)}
|
|
||||||
onChange={(value) => setPaymentCurrency(value?.value)}
|
onChange={(value) => setPaymentCurrency(value?.value)}
|
||||||
menuPortalTarget={document?.body}
|
menuPortalTarget={document?.body}
|
||||||
styles={{
|
styles={{
|
||||||
@@ -384,34 +358,24 @@ const UserCard = ({
|
|||||||
}),
|
}),
|
||||||
option: (styles, state) => ({
|
option: (styles, state) => ({
|
||||||
...styles,
|
...styles,
|
||||||
backgroundColor: state.isFocused
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
? "#D5D9F0"
|
|
||||||
: state.isSelected
|
|
||||||
? "#7872BF"
|
|
||||||
: "white",
|
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 w-full">
|
<div className="flex gap-3 w-full">
|
||||||
<div className="flex flex-col gap-3 w-8/12">
|
<div className="flex flex-col gap-3 w-8/12">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">
|
<label className="font-normal text-base text-mti-gray-dim">Country Manager</label>
|
||||||
Country Manager
|
|
||||||
</label>
|
|
||||||
{referralAgentLabel && (
|
{referralAgentLabel && (
|
||||||
<Select
|
<Select
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
|
"px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
|
||||||
(checkAccess(
|
(checkAccess(loggedInUser, getTypesOfUser(["developer", "admin"])) || disabledFields.countryManager) &&
|
||||||
loggedInUser,
|
"!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
||||||
getTypesOfUser(["developer", "admin"])
|
|
||||||
) ||
|
|
||||||
disabledFields.countryManager) &&
|
|
||||||
"!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed"
|
|
||||||
)}
|
)}
|
||||||
options={[
|
options={[
|
||||||
{value: "", label: "No referral"},
|
{value: "", label: "No referral"},
|
||||||
@@ -441,30 +405,19 @@ const UserCard = ({
|
|||||||
}),
|
}),
|
||||||
option: (styles, state) => ({
|
option: (styles, state) => ({
|
||||||
...styles,
|
...styles,
|
||||||
backgroundColor: state.isFocused
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
? "#D5D9F0"
|
|
||||||
: state.isSelected
|
|
||||||
? "#7872BF"
|
|
||||||
: "white",
|
|
||||||
color: state.isFocused ? "black" : styles.color,
|
color: state.isFocused ? "black" : styles.color,
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
// editing country manager should only be available for dev/admin
|
// editing country manager should only be available for dev/admin
|
||||||
isDisabled={
|
isDisabled={checkAccess(loggedInUser, getTypesOfUser(["developer", "admin"])) || disabledFields.countryManager}
|
||||||
checkAccess(
|
|
||||||
loggedInUser,
|
|
||||||
getTypesOfUser(["developer", "admin"])
|
|
||||||
) || disabledFields.countryManager
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 w-4/12">
|
<div className="flex flex-col gap-3 w-4/12">
|
||||||
{referralAgent !== "" && loggedInUser.type !== "corporate" ? (
|
{referralAgent !== "" && loggedInUser.type !== "corporate" ? (
|
||||||
<>
|
<>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">
|
<label className="font-normal text-base text-mti-gray-dim">Commission</label>
|
||||||
Commission
|
|
||||||
</label>
|
|
||||||
<Input
|
<Input
|
||||||
name="commissionValue"
|
name="commissionValue"
|
||||||
onChange={(e) => setCommission(e ? parseInt(e) : undefined)}
|
onChange={(e) => setCommission(e ? parseInt(e) : undefined)}
|
||||||
@@ -488,10 +441,10 @@ const UserCard = ({
|
|||||||
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"
|
||||||
@@ -506,53 +459,52 @@ const UserCard = ({
|
|||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">
|
<label className="font-normal text-base text-mti-gray-dim">Country</label>
|
||||||
Country
|
<CountrySelect disabled value={user.demographicInformation?.country} />
|
||||||
</label>
|
|
||||||
<CountrySelect
|
|
||||||
disabled
|
|
||||||
value={user.demographicInformation?.country}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
type="tel"
|
type="tel"
|
||||||
name="phone"
|
name="phone"
|
||||||
label="Phone number"
|
label="Phone number"
|
||||||
onChange={() => null}
|
onChange={setPhone}
|
||||||
placeholder="Enter phone number"
|
placeholder="Enter phone number"
|
||||||
defaultValue={user.demographicInformation?.phone}
|
defaultValue={phone}
|
||||||
disabled
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{user.type === "student" && (
|
{user.type === "student" && (
|
||||||
|
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
name="passport_id"
|
name="passport_id"
|
||||||
label="Passport/National ID"
|
label="Passport/National ID"
|
||||||
onChange={() => null}
|
onChange={() => null}
|
||||||
placeholder="Enter National ID or Passport number"
|
placeholder="Enter National ID or Passport number"
|
||||||
value={
|
value={user.type === "student" ? user.demographicInformation?.passport_id : undefined}
|
||||||
user.type === "student"
|
|
||||||
? user.demographicInformation?.passport_id
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
disabled
|
disabled
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="studentID"
|
||||||
|
label="Student ID"
|
||||||
|
onChange={setStudentID}
|
||||||
|
placeholder="Enter Student ID"
|
||||||
|
disabled={!checkAccess(loggedInUser, getTypesOfUser(["teacher", "agent", "student"]), permissions, "editStudent")}
|
||||||
|
value={studentID}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||||
{user.type !== "corporate" && user.type !== "mastercorporate" && (
|
{user.type !== "corporate" && user.type !== "mastercorporate" && (
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">
|
<label className="font-normal text-base text-mti-gray-dim">Employment Status</label>
|
||||||
Employment Status
|
|
||||||
</label>
|
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={user.demographicInformation?.employment}
|
value={user.demographicInformation?.employment}
|
||||||
className="grid grid-cols-2 items-center gap-4 place-items-center"
|
className="grid grid-cols-2 items-center gap-4 place-items-center"
|
||||||
disabled={disabled}
|
disabled={disabled}>
|
||||||
>
|
|
||||||
{EMPLOYMENT_STATUS.map(({status, label}) => (
|
{EMPLOYMENT_STATUS.map(({status, label}) => (
|
||||||
<RadioGroup.Option value={status} key={status}>
|
<RadioGroup.Option value={status} key={status}>
|
||||||
{({checked}) => (
|
{({checked}) => (
|
||||||
@@ -562,9 +514,8 @@ const UserCard = ({
|
|||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
!checked
|
!checked
|
||||||
? "bg-white border-mti-gray-platinum"
|
? "bg-white border-mti-gray-platinum"
|
||||||
: "bg-mti-purple-light border-mti-purple-dark text-white"
|
: "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -573,12 +524,12 @@ const UserCard = ({
|
|||||||
</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
|
||||||
@@ -587,14 +538,12 @@ const UserCard = ({
|
|||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-8 w-full">
|
<div className="flex flex-col gap-8 w-full">
|
||||||
<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">
|
<label className="font-normal text-base text-mti-gray-dim">Gender</label>
|
||||||
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">
|
||||||
{({checked}) => (
|
{({checked}) => (
|
||||||
<span
|
<span
|
||||||
@@ -603,9 +552,8 @@ const UserCard = ({
|
|||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
!checked
|
!checked
|
||||||
? "bg-white border-mti-gray-platinum"
|
? "bg-white border-mti-gray-platinum"
|
||||||
: "bg-mti-purple-light border-mti-purple-dark text-white"
|
: "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
Male
|
Male
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -618,9 +566,8 @@ const UserCard = ({
|
|||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
!checked
|
!checked
|
||||||
? "bg-white border-mti-gray-platinum"
|
? "bg-white border-mti-gray-platinum"
|
||||||
: "bg-mti-purple-light border-mti-purple-dark text-white"
|
: "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
Female
|
Female
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -633,9 +580,8 @@ const UserCard = ({
|
|||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
!checked
|
!checked
|
||||||
? "bg-white border-mti-gray-platinum"
|
? "bg-white border-mti-gray-platinum"
|
||||||
: "bg-mti-purple-light border-mti-purple-dark text-white"
|
: "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
Other
|
Other
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -644,20 +590,15 @@ const UserCard = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">
|
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
|
||||||
Expiry Date
|
|
||||||
</label>
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
isChecked={!!expiryDate}
|
isChecked={!!expiryDate}
|
||||||
onChange={(checked) =>
|
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : null)}
|
||||||
setExpiryDate(
|
disabled={
|
||||||
checked
|
disabled ||
|
||||||
? user.subscriptionExpirationDate || new Date()
|
(!["admin", "developer", "mastercorporate", "corporate"].includes(loggedInUser.type) &&
|
||||||
: null
|
!!loggedInUser.subscriptionExpirationDate)
|
||||||
)
|
}>
|
||||||
}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
Enabled
|
Enabled
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
@@ -666,12 +607,9 @@ const UserCard = ({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"p-6 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
"p-6 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
!expiryDate
|
!expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(expiryDate),
|
||||||
? "!bg-mti-green-ultralight !border-mti-green-light"
|
"bg-white border-mti-gray-platinum",
|
||||||
: expirationDateColor(expiryDate),
|
)}>
|
||||||
"bg-white border-mti-gray-platinum"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{!expiryDate && "Unlimited"}
|
{!expiryDate && "Unlimited"}
|
||||||
{expiryDate && moment(expiryDate).format("DD/MM/YYYY")}
|
{expiryDate && moment(expiryDate).format("DD/MM/YYYY")}
|
||||||
</div>
|
</div>
|
||||||
@@ -682,14 +620,12 @@ const UserCard = ({
|
|||||||
"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",
|
||||||
"hover:border-mti-purple tooltip",
|
"hover:border-mti-purple tooltip",
|
||||||
expirationDateColor(expiryDate),
|
expirationDateColor(expiryDate),
|
||||||
"transition duration-300 ease-in-out"
|
"transition duration-300 ease-in-out",
|
||||||
)}
|
)}
|
||||||
filterDate={(date) =>
|
filterDate={(date) =>
|
||||||
moment(date).isAfter(new Date()) &&
|
moment(date).isAfter(new Date()) &&
|
||||||
(loggedInUser.subscriptionExpirationDate
|
(loggedInUser.subscriptionExpirationDate
|
||||||
? moment(date).isBefore(
|
? moment(date).isBefore(moment(loggedInUser.subscriptionExpirationDate))
|
||||||
moment(loggedInUser.subscriptionExpirationDate)
|
|
||||||
)
|
|
||||||
: true)
|
: true)
|
||||||
}
|
}
|
||||||
dateFormat="dd/MM/yyyy"
|
dateFormat="dd/MM/yyyy"
|
||||||
@@ -701,22 +637,26 @@ const UserCard = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{checkAccess(loggedInUser, ["developer", "admin"]) && (
|
{checkAccess(
|
||||||
|
loggedInUser,
|
||||||
|
["developer", "admin", "corporate", "mastercorporate"],
|
||||||
|
permissions,
|
||||||
|
user.type === "teacher" ? "editTeacher" : user.type === "student" ? "editStudent" : undefined,
|
||||||
|
) && (
|
||||||
<>
|
<>
|
||||||
<Divider className="w-full !m-0" />
|
<Divider className="w-full !m-0" />
|
||||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||||
<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">
|
<label className="font-normal text-base text-mti-gray-dim">Status</label>
|
||||||
Status
|
|
||||||
</label>
|
|
||||||
<Select
|
<Select
|
||||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
options={USER_STATUS_OPTIONS}
|
options={USER_STATUS_OPTIONS.filter((x) => {
|
||||||
|
if (checkAccess(loggedInUser, ["admin", "developer"])) return true;
|
||||||
|
return x.value !== "paymentDue";
|
||||||
|
})}
|
||||||
menuPortalTarget={document?.body}
|
menuPortalTarget={document?.body}
|
||||||
value={USER_STATUS_OPTIONS.find((o) => o.value === status)}
|
value={USER_STATUS_OPTIONS.find((o) => o.value === status)}
|
||||||
onChange={(value) =>
|
onChange={(value) => setStatus(value?.value as typeof user.status)}
|
||||||
setStatus(value?.value as typeof user.status)
|
|
||||||
}
|
|
||||||
styles={{
|
styles={{
|
||||||
control: (styles) => ({
|
control: (styles) => ({
|
||||||
...styles,
|
...styles,
|
||||||
@@ -730,11 +670,7 @@ const UserCard = ({
|
|||||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||||
option: (styles, state) => ({
|
option: (styles, state) => ({
|
||||||
...styles,
|
...styles,
|
||||||
backgroundColor: state.isFocused
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
? "#D5D9F0"
|
|
||||||
: state.isSelected
|
|
||||||
? "#7872BF"
|
|
||||||
: "white",
|
|
||||||
color: state.isFocused ? "black" : styles.color,
|
color: state.isFocused ? "black" : styles.color,
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
@@ -742,17 +678,34 @@ const UserCard = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">
|
<label className="font-normal text-base text-mti-gray-dim">Type</label>
|
||||||
Type
|
|
||||||
</label>
|
|
||||||
<Select
|
<Select
|
||||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
options={USER_TYPE_OPTIONS}
|
options={USER_TYPE_OPTIONS.filter((x) => {
|
||||||
|
if (x.value === "student")
|
||||||
|
return checkAccess(
|
||||||
|
loggedInUser,
|
||||||
|
["developer", "admin", "corporate", "mastercorporate"],
|
||||||
|
permissions,
|
||||||
|
"editStudent",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (x.value === "teacher")
|
||||||
|
return checkAccess(
|
||||||
|
loggedInUser,
|
||||||
|
["developer", "admin", "corporate", "mastercorporate"],
|
||||||
|
permissions,
|
||||||
|
"editTeacher",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (x.value === "corporate")
|
||||||
|
return checkAccess(loggedInUser, ["developer", "admin", "mastercorporate"], permissions, "editCorporate");
|
||||||
|
|
||||||
|
return checkAccess(loggedInUser, ["developer", "admin"]);
|
||||||
|
})}
|
||||||
menuPortalTarget={document?.body}
|
menuPortalTarget={document?.body}
|
||||||
value={USER_TYPE_OPTIONS.find((o) => o.value === type)}
|
value={USER_TYPE_OPTIONS.find((o) => o.value === type)}
|
||||||
onChange={(value) =>
|
onChange={(value) => setType(value?.value as typeof user.type)}
|
||||||
setType(value?.value as typeof user.type)
|
|
||||||
}
|
|
||||||
styles={{
|
styles={{
|
||||||
control: (styles) => ({
|
control: (styles) => ({
|
||||||
...styles,
|
...styles,
|
||||||
@@ -766,11 +719,7 @@ const UserCard = ({
|
|||||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||||
option: (styles, state) => ({
|
option: (styles, state) => ({
|
||||||
...styles,
|
...styles,
|
||||||
backgroundColor: state.isFocused
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
? "#D5D9F0"
|
|
||||||
: state.isSelected
|
|
||||||
? "#7872BF"
|
|
||||||
: "white",
|
|
||||||
color: state.isFocused ? "black" : styles.color,
|
color: state.isFocused ? "black" : styles.color,
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
@@ -785,56 +734,29 @@ const UserCard = ({
|
|||||||
<div className="flex gap-4 justify-between mt-4 w-full">
|
<div className="flex gap-4 justify-between mt-4 w-full">
|
||||||
<div className="self-start flex gap-4 justify-start items-center w-full">
|
<div className="self-start flex gap-4 justify-start items-center w-full">
|
||||||
{onViewCorporate && ["student", "teacher"].includes(user.type) && (
|
{onViewCorporate && ["student", "teacher"].includes(user.type) && (
|
||||||
<Button
|
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewCorporate}>
|
||||||
className="w-full max-w-[200px]"
|
|
||||||
variant="outline"
|
|
||||||
color="rose"
|
|
||||||
onClick={onViewCorporate}
|
|
||||||
>
|
|
||||||
View Corporate
|
View Corporate
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onViewStudents && ["corporate", "teacher"].includes(user.type) && (
|
{onViewStudents && ["corporate", "teacher"].includes(user.type) && (
|
||||||
<Button
|
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewStudents}>
|
||||||
className="w-full max-w-[200px]"
|
|
||||||
variant="outline"
|
|
||||||
color="rose"
|
|
||||||
onClick={onViewStudents}
|
|
||||||
>
|
|
||||||
View Students
|
View Students
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onViewTeachers && ["student", "corporate"].includes(user.type) && (
|
{onViewTeachers && ["student", "corporate"].includes(user.type) && (
|
||||||
<Button
|
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewTeachers}>
|
||||||
className="w-full max-w-[200px]"
|
|
||||||
variant="outline"
|
|
||||||
color="rose"
|
|
||||||
onClick={onViewTeachers}
|
|
||||||
>
|
|
||||||
View Teachers
|
View Teachers
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="self-end flex gap-4 w-full justify-end">
|
<div className="self-end flex gap-4 w-full justify-end">
|
||||||
<Button
|
<Button className="w-full max-w-[200px]" variant="outline" onClick={onClose}>
|
||||||
className="w-full max-w-[200px]"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={
|
disabled={disabled || !checkAccess(loggedInUser, updateUserPermission.list, permissions, updateUserPermission.perm)}
|
||||||
disabled ||
|
|
||||||
!checkAccess(
|
|
||||||
loggedInUser,
|
|
||||||
updateUserPermission.list,
|
|
||||||
updateUserPermission.perm
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={updateUser}
|
onClick={updateUser}
|
||||||
className="w-full max-w-[200px]"
|
className="w-full max-w-[200px]">
|
||||||
>
|
|
||||||
Update
|
Update
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
31
src/components/ui/popover.tsx
Normal file
31
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
|
||||||
|
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border border-neutral-200 bg-white p-4 text-neutral-950 shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
@@ -45,16 +45,16 @@ export const PERMISSIONS = {
|
|||||||
updateUser: {
|
updateUser: {
|
||||||
student: {
|
student: {
|
||||||
perm: "editStudent",
|
perm: "editStudent",
|
||||||
list: ["developer", "admin"],
|
list: ["developer", "admin", "corporate", "mastercorporate", "teacher"],
|
||||||
},
|
},
|
||||||
teacher: {
|
teacher: {
|
||||||
perm: "editTeacher",
|
perm: "editTeacher",
|
||||||
list: ["developer", "admin"],
|
list: ["developer", "admin", "corporate", "mastercorporate"],
|
||||||
},
|
},
|
||||||
|
|
||||||
corporate: {
|
corporate: {
|
||||||
perm: "editCorporate",
|
perm: "editCorporate",
|
||||||
list: ["admin", "developer"],
|
list: ["developer", "admin", "mastercorporate"],
|
||||||
},
|
},
|
||||||
mastercorporate: {
|
mastercorporate: {
|
||||||
perm: undefined,
|
perm: undefined,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useStats from "@/hooks/useStats";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import { User } from "@/interfaces/user";
|
import {Stat, User} from "@/interfaces/user";
|
||||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
import {dateSorter} from "@/utils";
|
import {dateSorter} from "@/utils";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
@@ -31,45 +31,61 @@ interface Props {
|
|||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const studentHash = {
|
||||||
|
type: "student",
|
||||||
|
size: 25,
|
||||||
|
orderBy: "registrationDate",
|
||||||
|
};
|
||||||
|
|
||||||
|
const teacherHash = {
|
||||||
|
type: "teacher",
|
||||||
|
size: 25,
|
||||||
|
orderBy: "registrationDate",
|
||||||
|
};
|
||||||
|
|
||||||
|
const corporateHash = {
|
||||||
|
type: "corporate",
|
||||||
|
size: 25,
|
||||||
|
orderBy: "registrationDate",
|
||||||
|
};
|
||||||
|
|
||||||
|
const agentsHash = {
|
||||||
|
type: "agent",
|
||||||
|
size: 25,
|
||||||
|
orderBy: "registrationDate",
|
||||||
|
};
|
||||||
|
|
||||||
export default function AdminDashboard({user}: Props) {
|
export default function AdminDashboard({user}: Props) {
|
||||||
const [page, setPage] = useState("");
|
const [page, setPage] = useState("");
|
||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
const { stats } = useStats(user.id);
|
const {users: students, total: totalStudents, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(studentHash);
|
||||||
const { users, reload } = useUsers();
|
const {users: teachers, total: totalTeachers, reload: reloadTeachers, isLoading: isTeachersLoading} = useUsers(teacherHash);
|
||||||
const { groups } = useGroups();
|
const {users: corporates, total: totalCorporate, reload: reloadCorporates, isLoading: isCorporatesLoading} = useUsers(corporateHash);
|
||||||
|
const {users: agents, total: totalAgents, reload: reloadAgents, isLoading: isAgentsLoading} = useUsers(agentsHash);
|
||||||
|
|
||||||
|
const {groups} = useGroups({});
|
||||||
const {pending, done} = usePaymentStatusUsers();
|
const {pending, done} = usePaymentStatusUsers();
|
||||||
|
|
||||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowModal(!!selectedUser && page === "");
|
setShowModal(!!selectedUser && router.asPath === "/#");
|
||||||
}, [selectedUser, page]);
|
}, [selectedUser, router.asPath]);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
const inactiveCountryManagerFilter = (x: User) => x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate);
|
||||||
useEffect(reload, [page]);
|
|
||||||
|
|
||||||
const inactiveCountryManagerFilter = (x: User) =>
|
|
||||||
x.type === "agent" &&
|
|
||||||
(x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
|
||||||
|
|
||||||
const UserDisplay = (displayUser: User) => (
|
const UserDisplay = (displayUser: User) => (
|
||||||
<div
|
<div
|
||||||
onClick={() => setSelectedUser(displayUser)}
|
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"
|
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" />
|
||||||
<img
|
|
||||||
src={displayUser.profilePicture}
|
|
||||||
alt={displayUser.name}
|
|
||||||
className="rounded-full w-10 h-10"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col gap-1 items-start">
|
<div className="flex flex-col gap-1 items-start">
|
||||||
<span>
|
<span>
|
||||||
{displayUser.type === "corporate"
|
{displayUser.type === "corporate"
|
||||||
? displayUser.corporateInformation?.companyInformation?.name ||
|
? displayUser.corporateInformation?.companyInformation?.name || displayUser.name
|
||||||
displayUser.name
|
|
||||||
: displayUser.name}
|
: displayUser.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||||
@@ -79,28 +95,23 @@ 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(
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
(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>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,28 +124,23 @@ 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(
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
(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>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,24 +152,19 @@ 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>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">
|
<h2 className="text-2xl font-semibold">Country Managers ({total})</h2>
|
||||||
Country Managers ({total})
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -173,13 +174,12 @@ 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>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,18 +191,18 @@ 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>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,19 +219,17 @@ 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>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">
|
<h2 className="text-2xl font-semibold">Inactive Country Managers ({total})</h2>
|
||||||
Inactive Country Managers ({total})
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -239,27 +237,22 @@ export default function AdminDashboard({ user }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const InactiveStudentsList = () => {
|
const InactiveStudentsList = () => {
|
||||||
const filter = (x: User) =>
|
const filter = (x: User) => x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate);
|
||||||
x.type === "student" &&
|
|
||||||
(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>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">
|
<h2 className="text-2xl font-semibold">Inactive Students ({total})</h2>
|
||||||
Inactive Students ({total})
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -267,27 +260,22 @@ export default function AdminDashboard({ user }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const InactiveCorporateList = () => {
|
const InactiveCorporateList = () => {
|
||||||
const filter = (x: User) =>
|
const filter = (x: User) => x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate);
|
||||||
x.type === "corporate" &&
|
|
||||||
(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>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">
|
<h2 className="text-2xl font-semibold">Inactive Corporate ({total})</h2>
|
||||||
Inactive Corporate ({total})
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -299,15 +287,12 @@ 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>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">
|
<h2 className="text-2xl font-semibold">Corporate Students Levels</h2>
|
||||||
Corporate Students Levels
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<CorporateStudentsLevels />
|
<CorporateStudentsLevels />
|
||||||
</>
|
</>
|
||||||
@@ -319,90 +304,83 @@ 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={isStudentsLoading}
|
||||||
label="Students"
|
label="Students"
|
||||||
value={users.filter((x) => x.type === "student").length}
|
value={totalStudents}
|
||||||
onClick={() => setPage("students")}
|
onClick={() => router.push("/#students")}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsPencilSquare}
|
Icon={BsPencilSquare}
|
||||||
|
isLoading={isTeachersLoading}
|
||||||
label="Teachers"
|
label="Teachers"
|
||||||
value={users.filter((x) => x.type === "teacher").length}
|
value={totalTeachers}
|
||||||
onClick={() => setPage("teachers")}
|
onClick={() => router.push("/#teachers")}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsBank}
|
Icon={BsBank}
|
||||||
|
isLoading={isCorporatesLoading}
|
||||||
label="Corporate"
|
label="Corporate"
|
||||||
value={users.filter((x) => x.type === "corporate").length}
|
value={totalCorporate}
|
||||||
onClick={() => setPage("corporate")}
|
onClick={() => router.push("/#corporate")}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsBriefcaseFill}
|
Icon={BsBriefcaseFill}
|
||||||
|
isLoading={isAgentsLoading}
|
||||||
label="Country Managers"
|
label="Country Managers"
|
||||||
value={users.filter((x) => x.type === "agent").length}
|
value={totalAgents}
|
||||||
onClick={() => setPage("agents")}
|
onClick={() => router.push("/#agents")}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsGlobeCentralSouthAsia}
|
Icon={BsGlobeCentralSouthAsia}
|
||||||
|
isLoading={isAgentsLoading}
|
||||||
label="Countries"
|
label="Countries"
|
||||||
value={
|
value={[...new Set(agents.filter((x) => x.demographicInformation).map((x) => x.demographicInformation?.country))].length}
|
||||||
[
|
|
||||||
...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={isStudentsLoading}
|
||||||
label="Inactive Students"
|
label="Inactive Students"
|
||||||
value={
|
value={
|
||||||
users.filter(
|
students.filter((x) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
|
||||||
(x) =>
|
.length
|
||||||
x.type === "student" &&
|
|
||||||
(x.status === "disabled" ||
|
|
||||||
moment().isAfter(x.subscriptionExpirationDate))
|
|
||||||
).length
|
|
||||||
}
|
}
|
||||||
color="rose"
|
color="rose"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("inactiveCountryManagers")}
|
onClick={() => router.push("/#inactiveCountryManagers")}
|
||||||
Icon={BsBriefcaseFill}
|
Icon={BsBriefcaseFill}
|
||||||
|
isLoading={isAgentsLoading}
|
||||||
label="Inactive Country Managers"
|
label="Inactive Country Managers"
|
||||||
value={users.filter(inactiveCountryManagerFilter).length}
|
value={agents.filter(inactiveCountryManagerFilter).length}
|
||||||
color="rose"
|
color="rose"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("inactiveCorporate")}
|
onClick={() => router.push("/#inactiveCorporate")}
|
||||||
Icon={BsBank}
|
Icon={BsBank}
|
||||||
|
isLoading={isCorporatesLoading}
|
||||||
label="Inactive Corporate"
|
label="Inactive Corporate"
|
||||||
value={
|
value={
|
||||||
users.filter(
|
corporates.filter(
|
||||||
(x) =>
|
(x) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)),
|
||||||
x.type === "corporate" &&
|
|
||||||
(x.status === "disabled" ||
|
|
||||||
moment().isAfter(x.subscriptionExpirationDate))
|
|
||||||
).length
|
).length
|
||||||
}
|
}
|
||||||
color="rose"
|
color="rose"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("paymentdone")}
|
onClick={() => router.push("/#paymentdone")}
|
||||||
Icon={BsCurrencyDollar}
|
Icon={BsCurrencyDollar}
|
||||||
label="Payment Done"
|
label="Payment Done"
|
||||||
value={done.length}
|
value={done.length}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("paymentpending")}
|
onClick={() => router.push("/#paymentpending")}
|
||||||
Icon={BsCurrencyDollar}
|
Icon={BsCurrencyDollar}
|
||||||
label="Pending Payment"
|
label="Pending Payment"
|
||||||
value={pending.length}
|
value={pending.length}
|
||||||
@@ -415,8 +393,9 @@ export default function AdminDashboard({ user }: Props) {
|
|||||||
color="green"
|
color="green"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("corporatestudentslevels")}
|
onClick={() => router.push("/#corporatestudentslevels")}
|
||||||
Icon={BsPersonFill}
|
Icon={BsPersonFill}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
label="Corporate Students Levels"
|
label="Corporate Students Levels"
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
@@ -426,8 +405,7 @@ export default function AdminDashboard({ user }: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Latest students</span>
|
<span className="p-4">Latest students</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{students
|
||||||
.filter((x) => x.type === "student")
|
|
||||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
@@ -437,8 +415,7 @@ export default function AdminDashboard({ user }: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Latest teachers</span>
|
<span className="p-4">Latest teachers</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{teachers
|
||||||
.filter((x) => x.type === "teacher")
|
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
return dateSorter(a, b, "desc", "registrationDate");
|
return dateSorter(a, b, "desc", "registrationDate");
|
||||||
})
|
})
|
||||||
@@ -450,8 +427,7 @@ export default function AdminDashboard({ user }: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Latest corporate</span>
|
<span className="p-4">Latest corporate</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{corporates
|
||||||
.filter((x) => x.type === "corporate")
|
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
return dateSorter(a, b, "desc", "registrationDate");
|
return dateSorter(a, b, "desc", "registrationDate");
|
||||||
})
|
})
|
||||||
@@ -463,10 +439,8 @@ export default function AdminDashboard({ user }: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Unpaid Corporate</span>
|
<span className="p-4">Unpaid Corporate</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{corporates
|
||||||
.filter(
|
.filter((x) => x.status === "paymentDue")
|
||||||
(x) => x.type === "corporate" && x.status === "paymentDue"
|
|
||||||
)
|
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
))}
|
))}
|
||||||
@@ -475,15 +449,12 @@ export default function AdminDashboard({ user }: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Students expiring in 1 month</span>
|
<span className="p-4">Students expiring in 1 month</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{students
|
||||||
.filter(
|
.filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
x.type === "student" &&
|
|
||||||
x.subscriptionExpirationDate &&
|
x.subscriptionExpirationDate &&
|
||||||
moment().isAfter(
|
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
||||||
moment(x.subscriptionExpirationDate).subtract(30, "days")
|
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
||||||
) &&
|
|
||||||
moment().isBefore(moment(x.subscriptionExpirationDate))
|
|
||||||
)
|
)
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
@@ -493,15 +464,12 @@ export default function AdminDashboard({ user }: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Teachers expiring in 1 month</span>
|
<span className="p-4">Teachers expiring in 1 month</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{teachers
|
||||||
.filter(
|
.filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
x.type === "teacher" &&
|
|
||||||
x.subscriptionExpirationDate &&
|
x.subscriptionExpirationDate &&
|
||||||
moment().isAfter(
|
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
||||||
moment(x.subscriptionExpirationDate).subtract(30, "days")
|
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
||||||
) &&
|
|
||||||
moment().isBefore(moment(x.subscriptionExpirationDate))
|
|
||||||
)
|
)
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
@@ -511,15 +479,12 @@ export default function AdminDashboard({ user }: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Country Manager expiring in 1 month</span>
|
<span className="p-4">Country Manager expiring in 1 month</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{agents
|
||||||
.filter(
|
.filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
x.type === "agent" &&
|
|
||||||
x.subscriptionExpirationDate &&
|
x.subscriptionExpirationDate &&
|
||||||
moment().isAfter(
|
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
||||||
moment(x.subscriptionExpirationDate).subtract(30, "days")
|
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
||||||
) &&
|
|
||||||
moment().isBefore(moment(x.subscriptionExpirationDate))
|
|
||||||
)
|
)
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
@@ -529,15 +494,12 @@ export default function AdminDashboard({ user }: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Corporate expiring in 1 month</span>
|
<span className="p-4">Corporate expiring in 1 month</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{corporates
|
||||||
.filter(
|
.filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
x.type === "corporate" &&
|
|
||||||
x.subscriptionExpirationDate &&
|
x.subscriptionExpirationDate &&
|
||||||
moment().isAfter(
|
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
||||||
moment(x.subscriptionExpirationDate).subtract(30, "days")
|
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
||||||
) &&
|
|
||||||
moment().isBefore(moment(x.subscriptionExpirationDate))
|
|
||||||
)
|
)
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
@@ -547,13 +509,8 @@ export default function AdminDashboard({ user }: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Expired Students</span>
|
<span className="p-4">Expired Students</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{students
|
||||||
.filter(
|
.filter((x) => x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)))
|
||||||
(x) =>
|
|
||||||
x.type === "student" &&
|
|
||||||
x.subscriptionExpirationDate &&
|
|
||||||
moment().isAfter(moment(x.subscriptionExpirationDate))
|
|
||||||
)
|
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
))}
|
))}
|
||||||
@@ -562,13 +519,8 @@ export default function AdminDashboard({ user }: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Expired Teachers</span>
|
<span className="p-4">Expired Teachers</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{teachers
|
||||||
.filter(
|
.filter((x) => x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)))
|
||||||
(x) =>
|
|
||||||
x.type === "teacher" &&
|
|
||||||
x.subscriptionExpirationDate &&
|
|
||||||
moment().isAfter(moment(x.subscriptionExpirationDate))
|
|
||||||
)
|
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
))}
|
))}
|
||||||
@@ -577,13 +529,8 @@ export default function AdminDashboard({ user }: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Expired Country Manager</span>
|
<span className="p-4">Expired Country Manager</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{agents
|
||||||
.filter(
|
.filter((x) => x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)))
|
||||||
(x) =>
|
|
||||||
x.type === "agent" &&
|
|
||||||
x.subscriptionExpirationDate &&
|
|
||||||
moment().isAfter(moment(x.subscriptionExpirationDate))
|
|
||||||
)
|
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
))}
|
))}
|
||||||
@@ -592,13 +539,8 @@ export default function AdminDashboard({ user }: Props) {
|
|||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Expired Corporate</span>
|
<span className="p-4">Expired Corporate</span>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{corporates
|
||||||
.filter(
|
.filter((x) => x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)))
|
||||||
(x) =>
|
|
||||||
x.type === "corporate" &&
|
|
||||||
x.subscriptionExpirationDate &&
|
|
||||||
moment().isAfter(moment(x.subscriptionExpirationDate))
|
|
||||||
)
|
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
))}
|
))}
|
||||||
@@ -618,11 +560,13 @@ export default function AdminDashboard({ user }: Props) {
|
|||||||
loggedInUser={user}
|
loggedInUser={user}
|
||||||
onClose={(shouldReload) => {
|
onClose={(shouldReload) => {
|
||||||
setSelectedUser(undefined);
|
setSelectedUser(undefined);
|
||||||
if (shouldReload) reload();
|
if (shouldReload && selectedUser!.type === "student") reloadStudents();
|
||||||
|
if (shouldReload && selectedUser!.type === "teacher") reloadTeachers();
|
||||||
|
if (shouldReload && selectedUser!.type === "corporate") reloadCorporates();
|
||||||
|
if (shouldReload && selectedUser!.type === "agent") reloadAgents();
|
||||||
}}
|
}}
|
||||||
onViewStudents={
|
onViewStudents={
|
||||||
selectedUser.type === "corporate" ||
|
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
||||||
selectedUser.type === "teacher"
|
|
||||||
? () => {
|
? () => {
|
||||||
appendUserFilters({
|
appendUserFilters({
|
||||||
id: "view-students",
|
id: "view-students",
|
||||||
@@ -632,11 +576,7 @@ export default function AdminDashboard({ user }: Props) {
|
|||||||
id: "belongs-to-admin",
|
id: "belongs-to-admin",
|
||||||
filter: (x: User) =>
|
filter: (x: User) =>
|
||||||
groups
|
groups
|
||||||
.filter(
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
(g) =>
|
|
||||||
g.admin === selectedUser.id ||
|
|
||||||
g.participants.includes(selectedUser.id)
|
|
||||||
)
|
|
||||||
.flatMap((g) => g.participants)
|
.flatMap((g) => g.participants)
|
||||||
.includes(x.id),
|
.includes(x.id),
|
||||||
});
|
});
|
||||||
@@ -646,8 +586,7 @@ export default function AdminDashboard({ user }: Props) {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onViewTeachers={
|
onViewTeachers={
|
||||||
selectedUser.type === "corporate" ||
|
selectedUser.type === "corporate" || selectedUser.type === "student"
|
||||||
selectedUser.type === "student"
|
|
||||||
? () => {
|
? () => {
|
||||||
appendUserFilters({
|
appendUserFilters({
|
||||||
id: "view-teachers",
|
id: "view-teachers",
|
||||||
@@ -657,11 +596,7 @@ export default function AdminDashboard({ user }: Props) {
|
|||||||
id: "belongs-to-admin",
|
id: "belongs-to-admin",
|
||||||
filter: (x: User) =>
|
filter: (x: User) =>
|
||||||
groups
|
groups
|
||||||
.filter(
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
(g) =>
|
|
||||||
g.admin === selectedUser.id ||
|
|
||||||
g.participants.includes(selectedUser.id)
|
|
||||||
)
|
|
||||||
.flatMap((g) => g.participants)
|
.flatMap((g) => g.participants)
|
||||||
.includes(x.id),
|
.includes(x.id),
|
||||||
});
|
});
|
||||||
@@ -671,8 +606,7 @@ export default function AdminDashboard({ user }: Props) {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onViewCorporate={
|
onViewCorporate={
|
||||||
selectedUser.type === "teacher" ||
|
selectedUser.type === "teacher" || selectedUser.type === "student"
|
||||||
selectedUser.type === "student"
|
|
||||||
? () => {
|
? () => {
|
||||||
appendUserFilters({
|
appendUserFilters({
|
||||||
id: "view-corporate",
|
id: "view-corporate",
|
||||||
@@ -682,9 +616,7 @@ export default function AdminDashboard({ user }: Props) {
|
|||||||
id: "belongs-to-admin",
|
id: "belongs-to-admin",
|
||||||
filter: (x: User) =>
|
filter: (x: User) =>
|
||||||
groups
|
groups
|
||||||
.filter((g) =>
|
.filter((g) => g.participants.includes(selectedUser.id))
|
||||||
g.participants.includes(selectedUser.id)
|
|
||||||
)
|
|
||||||
.flatMap((g) => [g.admin, ...g.participants])
|
.flatMap((g) => [g.admin, ...g.participants])
|
||||||
.includes(x.id),
|
.includes(x.id),
|
||||||
});
|
});
|
||||||
@@ -699,17 +631,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,18 +1,13 @@
|
|||||||
/* 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";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {
|
import {BsArrowLeft, BsPersonFill, BsBank, BsCurrencyDollar} from "react-icons/bs";
|
||||||
BsArrowLeft,
|
|
||||||
BsPersonFill,
|
|
||||||
BsBank,
|
|
||||||
BsCurrencyDollar,
|
|
||||||
} from "react-icons/bs";
|
|
||||||
import UserCard from "@/components/UserCard";
|
import UserCard from "@/components/UserCard";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
|
||||||
@@ -28,9 +23,8 @@ export default function AgentDashboard({ user }: Props) {
|
|||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
const { stats } = useStats();
|
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
||||||
const {users, reload} = useUsers();
|
const {users, reload} = useUsers();
|
||||||
const { groups } = useGroups(user.id);
|
|
||||||
const {pending, done} = usePaymentStatusUsers();
|
const {pending, done} = usePaymentStatusUsers();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,34 +33,19 @@ export default function AgentDashboard({ user }: Props) {
|
|||||||
|
|
||||||
const corporateFilter = (user: User) => user.type === "corporate";
|
const corporateFilter = (user: User) => user.type === "corporate";
|
||||||
const referredCorporateFilter = (x: User) =>
|
const referredCorporateFilter = (x: User) =>
|
||||||
x.type === "corporate" &&
|
x.type === "corporate" && !!x.corporateInformation && x.corporateInformation.referralAgent === user.id;
|
||||||
!!x.corporateInformation &&
|
|
||||||
x.corporateInformation.referralAgent === user.id;
|
|
||||||
const inactiveReferredCorporateFilter = (x: User) =>
|
const inactiveReferredCorporateFilter = (x: User) =>
|
||||||
referredCorporateFilter(x) &&
|
referredCorporateFilter(x) && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
||||||
(x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
|
||||||
|
|
||||||
const UserDisplay = ({
|
const UserDisplay = ({displayUser, allowClick = true}: {displayUser: User; allowClick?: boolean}) => (
|
||||||
displayUser,
|
|
||||||
allowClick = true,
|
|
||||||
}: {
|
|
||||||
displayUser: User;
|
|
||||||
allowClick?: boolean;
|
|
||||||
}) => (
|
|
||||||
<div
|
<div
|
||||||
onClick={() => allowClick && setSelectedUser(displayUser)}
|
onClick={() => allowClick && setSelectedUser(displayUser)}
|
||||||
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"
|
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
||||||
>
|
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
||||||
<img
|
|
||||||
src={displayUser.profilePicture}
|
|
||||||
alt={displayUser.name}
|
|
||||||
className="rounded-full w-10 h-10"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col gap-1 items-start">
|
<div className="flex flex-col gap-1 items-start">
|
||||||
<span>
|
<span>
|
||||||
{displayUser.type === "corporate"
|
{displayUser.type === "corporate"
|
||||||
? displayUser.corporateInformation?.companyInformation?.name ||
|
? displayUser.corporateInformation?.companyInformation?.name || displayUser.name
|
||||||
displayUser.name
|
|
||||||
: displayUser.name}
|
: displayUser.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||||
@@ -83,14 +62,11 @@ export default function AgentDashboard({ user }: Props) {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
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"
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
>
|
|
||||||
<BsArrowLeft className="text-xl" />
|
<BsArrowLeft className="text-xl" />
|
||||||
<span>Back</span>
|
<span>Back</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">
|
<h2 className="text-2xl font-semibold">Referred Corporate ({total})</h2>
|
||||||
Referred Corporate ({total})
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -106,14 +82,11 @@ export default function AgentDashboard({ user }: Props) {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
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"
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
>
|
|
||||||
<BsArrowLeft className="text-xl" />
|
<BsArrowLeft className="text-xl" />
|
||||||
<span>Back</span>
|
<span>Back</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">
|
<h2 className="text-2xl font-semibold">Inactive Referred Corporate ({total})</h2>
|
||||||
Inactive Referred Corporate ({total})
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -131,8 +104,7 @@ export default function AgentDashboard({ user }: Props) {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
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"
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
>
|
|
||||||
<BsArrowLeft className="text-xl" />
|
<BsArrowLeft className="text-xl" />
|
||||||
<span>Back</span>
|
<span>Back</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,8 +127,7 @@ export default function AgentDashboard({ user }: Props) {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
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"
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
>
|
|
||||||
<BsArrowLeft className="text-xl" />
|
<BsArrowLeft className="text-xl" />
|
||||||
<span>Back</span>
|
<span>Back</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,13 +164,7 @@ export default function AgentDashboard({ user }: Props) {
|
|||||||
value={users.filter(corporateFilter).length}
|
value={users.filter(corporateFilter).length}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard onClick={() => setPage("paymentdone")} Icon={BsCurrencyDollar} label="Payment Done" value={done.length} color="purple" />
|
||||||
onClick={() => setPage("paymentdone")}
|
|
||||||
Icon={BsCurrencyDollar}
|
|
||||||
label="Payment Done"
|
|
||||||
value={done.length}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("paymentpending")}
|
onClick={() => setPage("paymentpending")}
|
||||||
Icon={BsCurrencyDollar}
|
Icon={BsCurrencyDollar}
|
||||||
@@ -239,10 +204,8 @@ export default function AgentDashboard({ user }: Props) {
|
|||||||
.filter(
|
.filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
referredCorporateFilter(x) &&
|
referredCorporateFilter(x) &&
|
||||||
moment().isAfter(
|
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
||||||
moment(x.subscriptionExpirationDate).subtract(30, "days")
|
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
||||||
) &&
|
|
||||||
moment().isBefore(moment(x.subscriptionExpirationDate))
|
|
||||||
)
|
)
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} displayUser={x} />
|
<UserDisplay key={x.id} displayUser={x} />
|
||||||
@@ -266,16 +229,9 @@ export default function AgentDashboard({ user }: Props) {
|
|||||||
if (shouldReload) reload();
|
if (shouldReload) reload();
|
||||||
}}
|
}}
|
||||||
onViewStudents={
|
onViewStudents={
|
||||||
selectedUser.type === "corporate" ||
|
selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
|
||||||
selectedUser.type === "teacher"
|
|
||||||
? () => setPage("students")
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onViewTeachers={
|
|
||||||
selectedUser.type === "corporate"
|
|
||||||
? () => setPage("teachers")
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
|
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
|
||||||
user={selectedUser}
|
user={selectedUser}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,9 +240,7 @@ export default function AgentDashboard({ user }: Props) {
|
|||||||
</Modal>
|
</Modal>
|
||||||
{page === "referredCorporate" && <ReferredCorporateList />}
|
{page === "referredCorporate" && <ReferredCorporateList />}
|
||||||
{page === "corporate" && <CorporateList />}
|
{page === "corporate" && <CorporateList />}
|
||||||
{page === "inactiveReferredCorporate" && (
|
{page === "inactiveReferredCorporate" && <InactiveReferredCorporateList />}
|
||||||
<InactiveReferredCorporateList />
|
|
||||||
)}
|
|
||||||
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
|
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
|
||||||
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
|
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
|
||||||
{page === "" && <DefaultDashboard />}
|
{page === "" && <DefaultDashboard />}
|
||||||
|
|||||||
@@ -10,13 +10,18 @@ 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 {User} from "@/interfaces/user";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
users: User[];
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
allowDownload?: boolean;
|
allowDownload?: boolean;
|
||||||
reload?: Function;
|
reload?: Function;
|
||||||
allowArchive?: boolean;
|
allowArchive?: boolean;
|
||||||
allowUnarchive?: boolean;
|
allowUnarchive?: boolean;
|
||||||
|
allowExcelDownload?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssignmentCard({
|
export default function AssignmentCard({
|
||||||
@@ -34,10 +39,16 @@ export default function AssignmentCard({
|
|||||||
reload,
|
reload,
|
||||||
allowArchive,
|
allowArchive,
|
||||||
allowUnarchive,
|
allowUnarchive,
|
||||||
|
allowExcelDownload,
|
||||||
|
users,
|
||||||
|
released,
|
||||||
}: Assignment & Props) {
|
}: Assignment & Props) {
|
||||||
const renderPdfIcon = usePDFDownload("assignments");
|
const renderPdfIcon = usePDFDownload("assignments");
|
||||||
|
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) => {
|
||||||
@@ -51,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}
|
||||||
@@ -59,9 +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")}
|
||||||
|
{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
|
||||||
@@ -72,13 +109,16 @@ export default function AssignmentCard({
|
|||||||
textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"}
|
textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
<span className="flex justify-between gap-1">
|
<span className="flex justify-between gap-1">
|
||||||
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span>Assigner: {getUserName(users.find((x) => x.id === assigner))}</span>
|
||||||
|
</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";
|
||||||
@@ -21,22 +21,40 @@ import Checkbox from "@/components/Low/Checkbox";
|
|||||||
import {InstructorGender, Variant} from "@/interfaces/exam";
|
import {InstructorGender, Variant} from "@/interfaces/exam";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
import useExams from "@/hooks/useExams";
|
import useExams from "@/hooks/useExams";
|
||||||
|
import {useListSearch} from "@/hooks/useListSearch";
|
||||||
|
|
||||||
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) {
|
const SIZE = 12;
|
||||||
|
|
||||||
|
export default function AssignmentCreator({isCreating, assignment, user, groups, users, cancelCreation}: Props) {
|
||||||
|
const [studentsPage, setStudentsPage] = useState(0);
|
||||||
|
const [teachersPage, setTeachersPage] = useState(0);
|
||||||
|
|
||||||
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 +62,42 @@ 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]);
|
||||||
|
|
||||||
|
const {rows: filteredStudentsRows, renderSearch: renderStudentSearch, text: studentText} = useListSearch([["name"], ["email"]], userStudents);
|
||||||
|
const {rows: filteredTeachersRows, renderSearch: renderTeacherSearch, text: teacherText} = useListSearch([["name"], ["email"]], userTeachers);
|
||||||
|
|
||||||
|
useEffect(() => setStudentsPage(0), [studentText]);
|
||||||
|
const studentRows = useMemo(
|
||||||
|
() =>
|
||||||
|
filteredStudentsRows.slice(
|
||||||
|
studentsPage * SIZE,
|
||||||
|
(studentsPage + 1) * SIZE > filteredStudentsRows.length ? filteredStudentsRows.length : (studentsPage + 1) * SIZE,
|
||||||
|
),
|
||||||
|
[filteredStudentsRows, studentsPage],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => setTeachersPage(0), [teacherText]);
|
||||||
|
const teacherRows = useMemo(
|
||||||
|
() =>
|
||||||
|
filteredTeachersRows.slice(
|
||||||
|
teachersPage * SIZE,
|
||||||
|
(teachersPage + 1) * SIZE > filteredTeachersRows.length ? filteredTeachersRows.length : (teachersPage + 1) * SIZE,
|
||||||
|
),
|
||||||
|
[filteredTeachersRows, teachersPage],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module)));
|
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module)));
|
||||||
}, [selectedModules]);
|
}, [selectedModules]);
|
||||||
@@ -62,6 +111,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 +126,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 +163,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 +205,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 +217,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 +257,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 +269,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 +306,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={[
|
||||||
@@ -285,9 +376,9 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="w-full flex flex-col gap-3">
|
<section className="w-full flex flex-col gap-4">
|
||||||
<span className="font-semibold">Assignees ({assignees.length} selected)</span>
|
<span className="font-semibold">Assignees ({assignees.length} selected)</span>
|
||||||
<div className="flex gap-4 overflow-x-scroll scrollbar-hide">
|
<div className="grid grid-cols-5 gap-4">
|
||||||
{groups.map((g) => (
|
{groups.map((g) => (
|
||||||
<button
|
<button
|
||||||
key={g.id}
|
key={g.id}
|
||||||
@@ -309,8 +400,11 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{renderStudentSearch()}
|
||||||
|
|
||||||
<div className="flex flex-wrap -md:justify-center gap-4">
|
<div className="flex flex-wrap -md:justify-center gap-4">
|
||||||
{users.map((user) => (
|
{studentRows.map((user) => (
|
||||||
<div
|
<div
|
||||||
onClick={() => toggleAssignee(user)}
|
onClick={() => toggleAssignee(user)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -340,20 +434,135 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-full flex gap-2 justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4 w-fit">
|
||||||
|
<Button className="w-[200px] h-fit" disabled={studentsPage === 0} onClick={() => setStudentsPage((prev) => prev - 1)}>
|
||||||
|
Previous Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 w-fit">
|
||||||
|
<span className="opacity-80">
|
||||||
|
{studentsPage * SIZE + 1} -{" "}
|
||||||
|
{(studentsPage + 1) * SIZE > filteredStudentsRows.length ? filteredStudentsRows.length : (studentsPage + 1) * SIZE} /{" "}
|
||||||
|
{filteredStudentsRows.length}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
className="w-[200px]"
|
||||||
|
disabled={(studentsPage + 1) * SIZE >= filteredStudentsRows.length}
|
||||||
|
onClick={() => setStudentsPage((prev) => prev + 1)}>
|
||||||
|
Next Page
|
||||||
|
</Button>
|
||||||
|
</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="grid grid-cols-5 gap-4">
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{renderTeacherSearch()}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap -md:justify-center gap-4">
|
||||||
|
{teacherRows.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>
|
||||||
|
|
||||||
|
<div className="w-full flex gap-2 justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4 w-fit">
|
||||||
|
<Button className="w-[200px] h-fit" disabled={teachersPage === 0} onClick={() => setTeachersPage((prev) => prev - 1)}>
|
||||||
|
Previous Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 w-fit">
|
||||||
|
<span className="opacity-80">
|
||||||
|
{teachersPage * SIZE + 1} -{" "}
|
||||||
|
{(teachersPage + 1) * SIZE > filteredTeachersRows.length
|
||||||
|
? filteredTeachersRows.length
|
||||||
|
: (teachersPage + 1) * SIZE}{" "}
|
||||||
|
/ {filteredTeachersRows.length}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
className="w-[200px]"
|
||||||
|
disabled={(teachersPage + 1) * SIZE >= filteredTeachersRows.length}
|
||||||
|
onClick={() => setTeachersPage((prev) => prev + 1)}>
|
||||||
|
Next Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-4 w-full items-end">
|
||||||
<Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
|
<Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
|
||||||
Full length exams
|
Full length exams
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
|
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
|
||||||
Generate different exams
|
Generate different exams
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
|
<Checkbox isChecked={released} onChange={() => setReleased((d) => !d)}>
|
||||||
|
Auto release results
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox isChecked={autoStart} onChange={() => setAutostart((d) => !d)}>
|
||||||
|
Auto start exam
|
||||||
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 w-full justify-end">
|
<div className="flex gap-4 w-full justify-end">
|
||||||
<Button className="w-full max-w-[200px]" variant="outline" onClick={cancelCreation} disabled={isLoading} isLoading={isLoading}>
|
<Button className="w-full max-w-[200px]" variant="outline" onClick={cancelCreation} disabled={isLoading} isLoading={isLoading}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
{assignment && (
|
{assignment && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
color="green"
|
||||||
|
variant="outline"
|
||||||
|
onClick={startAssignment}
|
||||||
|
disabled={isLoading || moment().isAfter(startDate)}
|
||||||
|
isLoading={isLoading}>
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="w-full max-w-[200px]"
|
className="w-full max-w-[200px]"
|
||||||
color="red"
|
color="red"
|
||||||
@@ -363,6 +572,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
isLoading={isLoading}>
|
isLoading={isLoading}>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
disabled={
|
disabled={
|
||||||
@@ -371,7 +581,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
!startDate ||
|
!startDate ||
|
||||||
!endDate ||
|
!endDate ||
|
||||||
assignees.length === 0 ||
|
assignees.length === 0 ||
|
||||||
(!!examIDs && examIDs.length < selectedModules.length)
|
(!useRandomExams && examIDs.length < selectedModules.length)
|
||||||
}
|
}
|
||||||
className="w-full max-w-[200px]"
|
className="w-full max-w-[200px]"
|
||||||
onClick={createAssignment}
|
onClick={createAssignment}
|
||||||
|
|||||||
@@ -10,13 +10,21 @@ import {getExamById} from "@/utils/exams";
|
|||||||
import { sortByModule } from "@/utils/moduleUtils";
|
import { sortByModule } from "@/utils/moduleUtils";
|
||||||
import { calculateBandScore } from "@/utils/score";
|
import { calculateBandScore } from "@/utils/score";
|
||||||
import { convertToUserSolutions } from "@/utils/stats";
|
import { convertToUserSolutions } from "@/utils/stats";
|
||||||
|
import { getUserName } from "@/utils/users";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { capitalize, uniqBy } from "lodash";
|
import { capitalize, uniqBy } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
import {
|
||||||
|
BsBook,
|
||||||
|
BsClipboard,
|
||||||
|
BsHeadphones,
|
||||||
|
BsMegaphone,
|
||||||
|
BsPen,
|
||||||
|
} from "react-icons/bs";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
import { futureAssignmentFilter } from "@/utils/assignments";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -38,11 +46,31 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
|
|
||||||
axios
|
axios
|
||||||
.delete(`/api/assignments/${assignment?.id}`)
|
.delete(`/api/assignments/${assignment?.id}`)
|
||||||
.then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`))
|
.then(() =>
|
||||||
|
toast.success(
|
||||||
|
`Successfully deleted the assignment "${assignment?.name}".`
|
||||||
|
)
|
||||||
|
)
|
||||||
.catch(() => toast.error("Something went wrong, please try again later."))
|
.catch(() => toast.error("Something went wrong, please try again later."))
|
||||||
.finally(onClose);
|
.finally(onClose);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startAssignment = () => {
|
||||||
|
if (assignment) {
|
||||||
|
axios
|
||||||
|
.post(`/api/assignments/${assignment.id}/start`)
|
||||||
|
.then(() => {
|
||||||
|
toast.success(
|
||||||
|
`The assignment "${assignment.name}" has been started successfully!`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.log(e);
|
||||||
|
toast.error("Something went wrong, please try again later!");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatTimestamp = (timestamp: string) => {
|
const formatTimestamp = (timestamp: string) => {
|
||||||
const date = moment(parseInt(timestamp));
|
const date = moment(parseInt(timestamp));
|
||||||
const formatter = "YYYY/MM/DD - HH:mm";
|
const formatter = "YYYY/MM/DD - HH:mm";
|
||||||
@@ -56,15 +84,26 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
const resultModuleBandScores = assignment.results.map((r) => {
|
const resultModuleBandScores = assignment.results.map((r) => {
|
||||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||||
|
|
||||||
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
const correct = moduleStats.reduce(
|
||||||
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
(acc, curr) => acc + curr.score.correct,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const total = moduleStats.reduce(
|
||||||
|
(acc, curr) => acc + curr.score.total,
|
||||||
|
0
|
||||||
|
);
|
||||||
return calculateBandScore(correct, total, module, r.type);
|
return calculateBandScore(correct, total, module, r.type);
|
||||||
});
|
});
|
||||||
|
|
||||||
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
|
return resultModuleBandScores.length === 0
|
||||||
|
? -1
|
||||||
|
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
|
||||||
|
assignment.results.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
const aggregateScoresByModule = (
|
||||||
|
stats: Stat[]
|
||||||
|
): { module: Module; total: number; missing: number; correct: number }[] => {
|
||||||
const scores: {
|
const scores: {
|
||||||
[key in Module]: { total: number; missing: number; correct: number };
|
[key in Module]: { total: number; missing: number; correct: number };
|
||||||
} = {
|
} = {
|
||||||
@@ -108,10 +147,22 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
|
const customContent = (
|
||||||
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
stats: Stat[],
|
||||||
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
user: string,
|
||||||
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
focus: "academic" | "general"
|
||||||
|
) => {
|
||||||
|
const correct = stats.reduce(
|
||||||
|
(accumulator, current) => accumulator + current.score.correct,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const total = stats.reduce(
|
||||||
|
(accumulator, current) => accumulator + current.score.total,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const aggregatedScores = aggregateScoresByModule(stats).filter(
|
||||||
|
(x) => x.total > 0
|
||||||
|
);
|
||||||
|
|
||||||
const aggregatedLevels = aggregatedScores.map((x) => ({
|
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||||
module: x.module,
|
module: x.module,
|
||||||
@@ -121,7 +172,9 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
const timeSpent = stats[0].timeSpent;
|
const timeSpent = stats[0].timeSpent;
|
||||||
|
|
||||||
const selectExam = () => {
|
const selectExam = () => {
|
||||||
const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
|
const examPromises = uniqBy(stats, "exam").map((stat) =>
|
||||||
|
getExamById(stat.module, stat.exam)
|
||||||
|
);
|
||||||
|
|
||||||
Promise.all(examPromises).then((exams) => {
|
Promise.all(examPromises).then((exams) => {
|
||||||
if (exams.every((x) => !!x)) {
|
if (exams.every((x) => !!x)) {
|
||||||
@@ -132,7 +185,7 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
exams
|
exams
|
||||||
.map((x) => x!)
|
.map((x) => x!)
|
||||||
.sort(sortByModule)
|
.sort(sortByModule)
|
||||||
.map((x) => x!.module),
|
.map((x) => x!.module)
|
||||||
);
|
);
|
||||||
router.push("/exercises");
|
router.push("/exercises");
|
||||||
}
|
}
|
||||||
@@ -143,11 +196,15 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
<>
|
<>
|
||||||
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
|
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
|
||||||
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
|
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
|
||||||
<span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
|
<span className="font-medium">
|
||||||
|
{formatTimestamp(stats[0].date.toString())}
|
||||||
|
</span>
|
||||||
{timeSpent && (
|
{timeSpent && (
|
||||||
<>
|
<>
|
||||||
<span className="md:hidden 2xl:flex">• </span>
|
<span className="md:hidden 2xl:flex">• </span>
|
||||||
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
|
<span className="text-sm">
|
||||||
|
{Math.floor(timeSpent / 60)} minutes
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -155,10 +212,16 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
correct / total >= 0.7 && "text-mti-purple",
|
correct / total >= 0.7 && "text-mti-purple",
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||||
correct / total < 0.3 && "text-mti-rose",
|
correct / total < 0.3 && "text-mti-rose"
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
Level{" "}
|
Level{" "}
|
||||||
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
{(
|
||||||
|
aggregatedLevels.reduce(
|
||||||
|
(accumulator, current) => accumulator + current.level,
|
||||||
|
0
|
||||||
|
) / aggregatedLevels.length
|
||||||
|
).toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -173,8 +236,9 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
module === "listening" && "bg-ielts-listening",
|
module === "listening" && "bg-ielts-listening",
|
||||||
module === "writing" && "bg-ielts-writing",
|
module === "writing" && "bg-ielts-writing",
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
module === "level" && "bg-ielts-level",
|
module === "level" && "bg-ielts-level"
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
@@ -201,11 +265,14 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
|
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
|
||||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
correct / total >= 0.3 &&
|
||||||
correct / total < 0.3 && "hover:border-mti-rose",
|
correct / total < 0.7 &&
|
||||||
|
"hover:border-mti-red",
|
||||||
|
correct / total < 0.3 && "hover:border-mti-rose"
|
||||||
)}
|
)}
|
||||||
onClick={selectExam}
|
onClick={selectExam}
|
||||||
role="button">
|
role="button"
|
||||||
|
>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -213,17 +280,30 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
|
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
|
||||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
correct / total >= 0.3 &&
|
||||||
correct / total < 0.3 && "hover:border-mti-rose",
|
correct / total < 0.7 &&
|
||||||
|
"hover:border-mti-red",
|
||||||
|
correct / total < 0.3 && "hover:border-mti-rose"
|
||||||
)}
|
)}
|
||||||
data-tip="Your screen size is too small to view previous exams."
|
data-tip="Your screen size is too small to view previous exams."
|
||||||
role="button">
|
role="button"
|
||||||
|
>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const shouldRenderStart = () => {
|
||||||
|
if (assignment) {
|
||||||
|
if (futureAssignmentFilter(assignment)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
|
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
|
||||||
<div className="mt-4 flex w-full flex-col gap-4">
|
<div className="mt-4 flex w-full flex-col gap-4">
|
||||||
@@ -232,15 +312,29 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
||||||
className="h-6"
|
className="h-6"
|
||||||
textClassName={
|
textClassName={
|
||||||
(assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"
|
(assignment?.results.length || 0) /
|
||||||
|
(assignment?.assignees.length || 1) <
|
||||||
|
0.5
|
||||||
|
? "!text-mti-gray-dim font-light"
|
||||||
|
: "text-white"
|
||||||
|
}
|
||||||
|
percentage={
|
||||||
|
((assignment?.results.length || 0) /
|
||||||
|
(assignment?.assignees.length || 1)) *
|
||||||
|
100
|
||||||
}
|
}
|
||||||
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex items-start gap-8">
|
<div className="flex items-start gap-8">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>
|
||||||
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
|
Start Date:{" "}
|
||||||
|
{moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
<span>
|
<span>
|
||||||
Assignees:{" "}
|
Assignees:{" "}
|
||||||
{users
|
{users
|
||||||
@@ -248,6 +342,11 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
.map((u) => `${u.name} (${u.email})`)
|
.map((u) => `${u.name} (${u.email})`)
|
||||||
.join(", ")}
|
.join(", ")}
|
||||||
</span>
|
</span>
|
||||||
|
<span>
|
||||||
|
Assigner:{" "}
|
||||||
|
{getUserName(users.find((x) => x.id === assignment?.assigner))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xl font-bold">Average Scores</span>
|
<span className="text-xl font-bold">Average Scores</span>
|
||||||
@@ -263,15 +362,20 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
module === "listening" && "bg-ielts-listening",
|
module === "listening" && "bg-ielts-listening",
|
||||||
module === "writing" && "bg-ielts-writing",
|
module === "writing" && "bg-ielts-writing",
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
module === "level" && "bg-ielts-level",
|
module === "level" && "bg-ielts-level"
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
{module === "listening" && (
|
||||||
|
<BsHeadphones className="h-4 w-4" />
|
||||||
|
)}
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||||
{calculateAverageModuleScore(module) > -1 && (
|
{calculateAverageModuleScore(module) > -1 && (
|
||||||
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
<span className="text-sm">
|
||||||
|
{calculateAverageModuleScore(module).toFixed(1)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -279,24 +383,47 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xl font-bold">
|
<span className="text-xl font-bold">
|
||||||
Results ({assignment?.results.length}/{assignment?.assignees.length})
|
Results ({assignment?.results.length}/{assignment?.assignees.length}
|
||||||
|
)
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
{assignment && assignment?.results.length > 0 && (
|
{assignment && assignment?.results.length > 0 && (
|
||||||
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
|
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
|
||||||
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
|
{assignment.results.map((r) =>
|
||||||
|
customContent(r.stats, r.user, r.type)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{assignment && assignment?.results.length === 0 && <span className="ml-1 font-semibold">No results yet...</span>}
|
{assignment && assignment?.results.length === 0 && (
|
||||||
|
<span className="ml-1 font-semibold">No results yet...</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 w-full items-center justify-end">
|
<div className="flex gap-4 w-full items-center justify-end">
|
||||||
{assignment && (assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && (
|
{assignment &&
|
||||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={deleteAssignment}>
|
(assignment.results.length === assignment.assignees.length ||
|
||||||
|
moment().isAfter(moment(assignment.endDate))) && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={deleteAssignment}
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{/** if the assignment is not deemed as active yet, display start */}
|
||||||
|
{shouldRenderStart() && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="green"
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={startAssignment}
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button onClick={onClose} className="w-full max-w-[200px]">
|
<Button onClick={onClose} className="w-full max-w-[200px]">
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,413 +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,
|
|
||||||
} from "react-icons/bs";
|
|
||||||
import UserCard from "@/components/UserCard";
|
|
||||||
import useGroups from "@/hooks/useGroups";
|
|
||||||
import { 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";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
user: CorporateUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 { stats } = useStats();
|
|
||||||
const { users, reload } = useUsers();
|
|
||||||
const { codes } = useCodes(user.id);
|
|
||||||
const { groups } = useGroups(user.id);
|
|
||||||
|
|
||||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setShowModal(!!selectedUser && page === "");
|
|
||||||
}, [selectedUser, page]);
|
|
||||||
|
|
||||||
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 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="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(
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</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 === "" && <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;
|
||||||
405
src/dashboards/Corporate/index.tsx
Normal file
405
src/dashboards/Corporate/index.tsx
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
/* 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,
|
||||||
|
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 {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";
|
||||||
|
import MasterStatisticalPage from "./MasterStatisticalPage";
|
||||||
|
|
||||||
|
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("/")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (router.asPath === "/#statistical") return <MasterStatisticalPage user={user} />;
|
||||||
|
|
||||||
|
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")}
|
||||||
|
/>
|
||||||
|
<IconCard Icon={BsDatabase} label="Master Statistical" 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,23 +1,17 @@
|
|||||||
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";
|
||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import {
|
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||||
BsBook,
|
|
||||||
BsClipboard,
|
|
||||||
BsHeadphones,
|
|
||||||
BsMegaphone,
|
|
||||||
BsPen,
|
|
||||||
} from "react-icons/bs";
|
|
||||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||||
import {capitalize} from "lodash";
|
import {capitalize} from "lodash";
|
||||||
import {getLevelLabel} from "@/utils/score";
|
import {getLevelLabel} from "@/utils/score";
|
||||||
|
|
||||||
const Card = ({user}: {user: User}) => {
|
const Card = ({user}: {user: User}) => {
|
||||||
return (
|
return (
|
||||||
<div className="border-mti-gray-platinum flex flex-col h-fit w-full cursor-pointer flex-col gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow">
|
<div className="border-mti-gray-platinum flex flex-col h-fit w-full cursor-pointer gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<h3 className="text-xl font-semibold">{user.name}</h3>
|
<h3 className="text-xl font-semibold">{user.name}</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,38 +20,19 @@ const Card = ({ user }: { user: User }) => {
|
|||||||
const desiredLevel = user.desiredLevels[module] || 9;
|
const desiredLevel = user.desiredLevels[module] || 9;
|
||||||
const level = user.levels[module] || 0;
|
const level = user.levels[module] || 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4 min-w-[250px]" key={module}>
|
||||||
className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4 min-w-[250px]"
|
|
||||||
key={module}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 md:gap-3">
|
<div className="flex items-center gap-2 md:gap-3">
|
||||||
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
|
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
|
||||||
{module === "reading" && (
|
{module === "reading" && <BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />}
|
||||||
<BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />
|
{module === "listening" && <BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />}
|
||||||
)}
|
{module === "writing" && <BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />}
|
||||||
{module === "listening" && (
|
{module === "speaking" && <BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />}
|
||||||
<BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />
|
{module === "level" && <BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />}
|
||||||
)}
|
|
||||||
{module === "writing" && (
|
|
||||||
<BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />
|
|
||||||
)}
|
|
||||||
{module === "speaking" && (
|
|
||||||
<BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />
|
|
||||||
)}
|
|
||||||
{module === "level" && (
|
|
||||||
<BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col">
|
||||||
<span className="text-sm font-bold md:font-extrabold w-full">
|
<span className="text-sm font-bold md:font-extrabold w-full">{capitalize(module)}</span>
|
||||||
{capitalize(module)}
|
|
||||||
</span>
|
|
||||||
<div className="text-mti-gray-dim text-sm font-normal">
|
<div className="text-mti-gray-dim text-sm font-normal">
|
||||||
{module === "level" && (
|
{module === "level" && <span>English Level: {getLevelLabel(level).join(" / ")}</span>}
|
||||||
<span>
|
|
||||||
English Level: {getLevelLabel(level).join(" / ")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{module !== "level" && (
|
{module !== "level" && (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span>Level {level} / Level 9</span>
|
<span>Level {level} / Level 9</span>
|
||||||
@@ -86,32 +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
|
const {users: students} = useUsers(userHashStudent);
|
||||||
? groups.filter((g) => g.admin === corporate.id)
|
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}`,
|
||||||
}))}
|
}))}
|
||||||
@@ -121,16 +81,12 @@ const CorporateStudentsLevels = () => {
|
|||||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||||
option: (styles, state) => ({
|
option: (styles, state) => ({
|
||||||
...styles,
|
...styles,
|
||||||
backgroundColor: state.isFocused
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
? "#D5D9F0"
|
|
||||||
: state.isSelected
|
|
||||||
? "#7872BF"
|
|
||||||
: "white",
|
|
||||||
color: state.isFocused ? "black" : styles.color,
|
color: state.isFocused ? "black" : styles.color,
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{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,424 +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 { 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,
|
|
||||||
} from "react-icons/bs";
|
|
||||||
import UserCard from "@/components/UserCard";
|
|
||||||
import useGroups from "@/hooks/useGroups";
|
|
||||||
|
|
||||||
import { 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";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
user: MasterCorporateUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MasterCorporateDashboard({ user }: Props) {
|
|
||||||
const [page, setPage] = useState("");
|
|
||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
|
|
||||||
const { stats } = useStats();
|
|
||||||
const { users, reload } = useUsers();
|
|
||||||
const { codes } = useCodes(user.id);
|
|
||||||
const { groups } = useGroups(user.id, 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 appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setShowModal(!!selectedUser && page === "");
|
|
||||||
}, [selectedUser, page]);
|
|
||||||
|
|
||||||
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 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 = () => (
|
|
||||||
<>
|
|
||||||
<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(
|
|
||||||
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")}
|
|
||||||
/>
|
|
||||||
</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 === "" && <DefaultDashboard />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
413
src/dashboards/MasterCorporate/MasterStatistical.tsx
Normal file
413
src/dashboards/MasterCorporate/MasterStatistical.tsx
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
import React, {useEffect, useMemo, useState} from "react";
|
||||||
|
import {CorporateUser, StudentUser, 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";
|
||||||
|
import {getUserName} from "@/utils/users";
|
||||||
|
|
||||||
|
interface GroupedCorporateUsers {
|
||||||
|
// list of user Ids
|
||||||
|
[key: string]: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
corporateUsers: GroupedCorporateUsers;
|
||||||
|
users: User[];
|
||||||
|
displaySelection?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableData {
|
||||||
|
user: User | undefined;
|
||||||
|
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"], ["exams"], ["assignment"]];
|
||||||
|
|
||||||
|
const SIZE = 16;
|
||||||
|
|
||||||
|
const MasterStatistical = (props: Props) => {
|
||||||
|
const {users, corporateUsers, displaySelection = true} = props;
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
|
||||||
|
// 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, isLoading} = useAssignmentsCorporates({
|
||||||
|
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 userData = users.find((u) => u.id === assignee);
|
||||||
|
const userStats = a.results.find((r) => r.user === assignee)?.stats || [];
|
||||||
|
const corporate = getUserName(users.find((u) => u.id === a.assigner));
|
||||||
|
const commonData = {
|
||||||
|
user: userData,
|
||||||
|
email: userData?.email || "N/A",
|
||||||
|
userId: assignee,
|
||||||
|
corporateId: a.corporateId,
|
||||||
|
exams: a.exams.map((x) => x.id).join(", "),
|
||||||
|
corporate,
|
||||||
|
assignment: a.name,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (userStats.length === 0) {
|
||||||
|
return {
|
||||||
|
...commonData,
|
||||||
|
correct: 0,
|
||||||
|
submitted: false,
|
||||||
|
date: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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()?.name || "N/A"}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("email", {
|
||||||
|
header: "Email",
|
||||||
|
id: "email",
|
||||||
|
cell: (info) => {
|
||||||
|
return <span>{info.getValue()}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("user", {
|
||||||
|
header: "Student ID",
|
||||||
|
id: "studentID",
|
||||||
|
cell: (info) => {
|
||||||
|
return <span>{(info.getValue() as StudentUser)?.studentID || "N/A"}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(displaySelection
|
||||||
|
? [
|
||||||
|
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: "Score",
|
||||||
|
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 ? date.format("DD/MM/YYYY") : "N/A"}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span>{""}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const {rows: filteredRows, renderSearch, text: searchText} = useListSearch(searchFilters, tableResults);
|
||||||
|
|
||||||
|
useEffect(() => setPage(0), [searchText]);
|
||||||
|
const rows = useMemo(
|
||||||
|
() => filteredRows.slice(page * SIZE, (page + 1) * SIZE > filteredRows.length ? filteredRows.length : (page + 1) * SIZE),
|
||||||
|
[filteredRows, page],
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: rows,
|
||||||
|
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"
|
||||||
|
isLoading={isLoading}
|
||||||
|
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}
|
||||||
|
isLoading={isLoading}
|
||||||
|
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="w-[200px] h-[70px]" variant="outline" isLoading={downloading} onClick={triggerDownload}>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full h-full flex flex-col gap-4">
|
||||||
|
<div className="w-full flex gap-2 justify-between">
|
||||||
|
<Button 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 * SIZE + 1} - {(page + 1) * SIZE > filteredRows.length ? filteredRows.length : (page + 1) * SIZE} /{" "}
|
||||||
|
{filteredRows.length}
|
||||||
|
</span>
|
||||||
|
<Button className="w-[200px]" disabled={(page + 1) * SIZE >= filteredRows.length} onClick={() => setPage((prev) => prev + 1)}>
|
||||||
|
Next Page
|
||||||
|
</Button>
|
||||||
|
</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;
|
||||||
43
src/dashboards/MasterCorporate/MasterStatisticalPage.tsx
Normal file
43
src/dashboards/MasterCorporate/MasterStatisticalPage.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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 {
|
||||||
|
groupedByNameCorporates: Record<string, CorporateUser[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MasterStatisticalPage = ({ groupedByNameCorporates }: Props) => {
|
||||||
|
const {users} = useUsers();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
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;
|
||||||
448
src/dashboards/MasterCorporate/index.tsx
Normal file
448
src/dashboards/MasterCorporate/index.tsx
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
/* 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 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 {users} = useUsers();
|
||||||
|
|
||||||
|
const groupedByNameCorporates = useMemo(
|
||||||
|
() =>
|
||||||
|
groupBy(
|
||||||
|
users.filter((x) => x.type === "corporate"),
|
||||||
|
(x: CorporateUser) => x.corporateInformation?.companyInformation?.name || "N/A",
|
||||||
|
) as Record<string, CorporateUser[]>,
|
||||||
|
[users],
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupedByNameCorporatesKeys = Object.keys(groupedByNameCorporates);
|
||||||
|
|
||||||
|
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 groupedByNameCorporates={groupedByNameCorporates} />;
|
||||||
|
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={groupedByNameCorporatesKeys.length}
|
||||||
|
isLoading={isCorporatesLoading}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
Icon={BsPersonFillGear}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
|
label="Student Performance"
|
||||||
|
value={totalStudents}
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,17 +3,18 @@ import ProgressBar from "@/components/Low/ProgressBar";
|
|||||||
import InviteCard from "@/components/Medium/InviteCard";
|
import InviteCard from "@/components/Medium/InviteCard";
|
||||||
import ProfileSummary from "@/components/ProfileSummary";
|
import ProfileSummary from "@/components/ProfileSummary";
|
||||||
import useAssignments from "@/hooks/useAssignments";
|
import useAssignments from "@/hooks/useAssignments";
|
||||||
|
import useGradingSystem from "@/hooks/useGrading";
|
||||||
import useInvites from "@/hooks/useInvites";
|
import useInvites from "@/hooks/useInvites";
|
||||||
import useStats from "@/hooks/useStats";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers, { userHashStudent, userHashTeacher, userHashCorporate} from "@/hooks/useUsers";
|
||||||
import {Invite} from "@/interfaces/invite";
|
import {Invite} from "@/interfaces/invite";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
import {CorporateUser, User} from "@/interfaces/user";
|
import {CorporateUser, MasterCorporateUser, Stat, User} from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {getExamById} from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
import {getUserCorporate} from "@/utils/groups";
|
import {getUserCorporate} from "@/utils/groups";
|
||||||
import {countExamModules, countFullExams, MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
|
import {countExamModules, countFullExams, MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
|
||||||
import {getLevelLabel, getLevelScore} from "@/utils/score";
|
import {getGradingLabel, getLevelLabel, getLevelScore} from "@/utils/score";
|
||||||
import {averageScore, groupBySession} from "@/utils/stats";
|
import {averageScore, groupBySession} from "@/utils/stats";
|
||||||
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
|
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
|
||||||
import {PayPalButtons} from "@paypal/react-paypal-js";
|
import {PayPalButtons} from "@paypal/react-paypal-js";
|
||||||
@@ -23,22 +24,29 @@ import {capitalize} from "lodash";
|
|||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useMemo, useState} from "react";
|
||||||
import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs";
|
import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
|
import {activeAssignmentFilter} from "@/utils/assignments";
|
||||||
|
import ModuleBadge from "@/components/ModuleBadge";
|
||||||
|
import useSessions from "@/hooks/useSessions";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
|
linkedCorporate?: CorporateUser | MasterCorporateUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StudentDashboard({user}: Props) {
|
export default function StudentDashboard({user, linkedCorporate}: Props) {
|
||||||
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
|
const {gradingSystem} = useGradingSystem();
|
||||||
|
const {sessions} = useSessions(user.id);
|
||||||
const {stats} = useStats(user.id, !user?.id);
|
const {data: stats} = useFilterRecordsByUser<Stat[]>(user.id, !user?.id);
|
||||||
const {users} = useUsers();
|
|
||||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
|
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
|
||||||
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user.id});
|
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user.id});
|
||||||
|
|
||||||
|
const {users: teachers} = useUsers(userHashTeacher);
|
||||||
|
const {users: corporates} = useUsers(userHashCorporate);
|
||||||
|
|
||||||
|
const users = useMemo(() => [...teachers, ...corporates], [teachers, corporates]);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
const setExams = useExamStore((state) => state.setExams);
|
||||||
@@ -47,10 +55,6 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||||
const setAssignment = useExamStore((state) => state.setAssignment);
|
const setAssignment = useExamStore((state) => state.setAssignment);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getUserCorporate(user.id).then(setCorporateUserToShow);
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const startAssignment = (assignment: Assignment) => {
|
const startAssignment = (assignment: Assignment) => {
|
||||||
const examPromises = assignment.exams.filter((e) => e.assignee === user.id).map((e) => getExamById(e.module, e.id));
|
const examPromises = assignment.exams.filter((e) => e.assignee === user.id).map((e) => getExamById(e.module, e.id));
|
||||||
|
|
||||||
@@ -72,11 +76,13 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const studentAssignments = assignments.filter(activeAssignmentFilter);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{corporateUserToShow && (
|
{linkedCorporate && (
|
||||||
<div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1">
|
<div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1">
|
||||||
Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b>
|
Linked to: <b>{linkedCorporate?.corporateInformation?.companyInformation.name || linkedCorporate.name}</b>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ProfileSummary
|
<ProfileSummary
|
||||||
@@ -122,50 +128,32 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||||
{assignments.filter((a) => moment(a.endDate).isSameOrAfter(moment())).length === 0 &&
|
{studentAssignments.length === 0 && "Assignments will appear here. It seems that for now there are no assignments for you."}
|
||||||
"Assignments will appear here. It seems that for now there are no assignments for you."}
|
{studentAssignments
|
||||||
{assignments
|
|
||||||
.filter((a) => moment(a.endDate).isSameOrAfter(moment()))
|
|
||||||
.sort((a, b) => moment(a.startDate).diff(b.startDate))
|
.sort((a, b) => moment(a.startDate).diff(b.startDate))
|
||||||
.map((assignment) => (
|
.map((assignment) => (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"border-mti-gray-anti-flash flex min-w-[300px] flex-col gap-6 rounded-xl border p-4",
|
"border-mti-gray-anti-flash flex min-w-[350px] flex-col gap-6 rounded-xl border p-4",
|
||||||
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light",
|
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light",
|
||||||
)}
|
)}
|
||||||
key={assignment.id}>
|
key={assignment.id}>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3>
|
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3>
|
||||||
<span className="flex justify-between gap-1">
|
<span className="flex justify-between gap-1 text-lg">
|
||||||
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
<div className="-md:mt-2 grid w-fit min-w-[104px] grid-cols-2 place-items-center justify-center gap-2">
|
<div className="-md:mt-2 grid w-fit min-w-[140px] grid-cols-2 grid-rows-2 place-items-center justify-between gap-4">
|
||||||
{assignment.exams
|
{assignment.exams
|
||||||
.filter((e) => e.assignee === user.id)
|
.filter((e) => e.assignee === user.id)
|
||||||
.map((e) => e.module)
|
.map((e) => e.module)
|
||||||
.sort(sortByModuleName)
|
.sort(sortByModuleName)
|
||||||
.map((module) => (
|
.map((module) => (
|
||||||
<div
|
<ModuleBadge className="scale-110 w-full" key={module} module={module} />
|
||||||
key={module}
|
|
||||||
data-tip={capitalize(module)}
|
|
||||||
className={clsx(
|
|
||||||
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
|
||||||
module === "reading" && "bg-ielts-reading",
|
|
||||||
module === "listening" && "bg-ielts-listening",
|
|
||||||
module === "writing" && "bg-ielts-writing",
|
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
|
||||||
module === "level" && "bg-ielts-level",
|
|
||||||
)}>
|
|
||||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
|
||||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
|
||||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
|
||||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!assignment.results.map((r) => r.user).includes(user.id) && (
|
{!assignment.results.map((r) => r.user).includes(user.id) && (
|
||||||
@@ -173,20 +161,24 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
<div
|
<div
|
||||||
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
|
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
|
||||||
data-tip="Your screen size is too small to perform an assignment">
|
data-tip="Your screen size is too small to perform an assignment">
|
||||||
<Button
|
<Button className="h-full w-full !rounded-xl" variant="outline">
|
||||||
disabled={moment(assignment.startDate).isAfter(moment())}
|
|
||||||
className="h-full w-full !rounded-xl"
|
|
||||||
variant="outline">
|
|
||||||
Start
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
data-tip="You have already started this assignment!"
|
||||||
|
className={clsx(
|
||||||
|
"-md:hidden h-full w-full max-w-[50%] cursor-pointer",
|
||||||
|
sessions.filter((x) => x.assignment?.id === assignment.id).length > 0 && "tooltip",
|
||||||
|
)}>
|
||||||
<Button
|
<Button
|
||||||
disabled={moment(assignment.startDate).isAfter(moment())}
|
className={clsx("w-full h-full !rounded-xl")}
|
||||||
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
|
||||||
onClick={() => startAssignment(assignment)}
|
onClick={() => startAssignment(assignment)}
|
||||||
variant="outline">
|
variant="outline"
|
||||||
|
disabled={sessions.filter((x) => x.assignment?.id === assignment.id).length > 0}>
|
||||||
Start
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{assignment.results.map((r) => r.user).includes(user.id) && (
|
{assignment.results.map((r) => r.user).includes(user.id) && (
|
||||||
@@ -243,7 +235,7 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
<div className="flex w-full justify-between">
|
<div className="flex w-full justify-between">
|
||||||
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span>
|
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span>
|
||||||
<span className="text-mti-gray-dim text-sm font-normal">
|
<span className="text-mti-gray-dim text-sm font-normal">
|
||||||
{module === "level" && `English Level: ${getLevelLabel(level).join(" / ")}`}
|
{module === "level" && !!gradingSystem && `English Level: ${getGradingLabel(level, gradingSystem.steps)}`}
|
||||||
{module !== "level" && `Level ${level} / Level 9 (Desired Level: ${desiredLevel})`}
|
{module !== "level" && `Level ${level} / Level 9 (Desired Level: ${desiredLevel})`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,9 +244,9 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
<ProgressBar
|
<ProgressBar
|
||||||
color={module}
|
color={module}
|
||||||
label=""
|
label=""
|
||||||
mark={Math.round((desiredLevel * 100) / 9)}
|
mark={module === "level" ? undefined : Math.round((desiredLevel * 100) / 9)}
|
||||||
markLabel={`Desired Level: ${desiredLevel}`}
|
markLabel={`Desired Level: ${desiredLevel}`}
|
||||||
percentage={Math.round((level * 100) / 9)}
|
percentage={module === "level" ? level : Math.round((level * 100) / 9)}
|
||||||
className="h-2 w-full"
|
className="h-2 w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useStats from "@/hooks/useStats";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers, {userHashStudent, userHashTeacher, userHashCorporate} from "@/hooks/useUsers";
|
||||||
import { CorporateUser, Group, Stat, User } from "@/interfaces/user";
|
import {CorporateUser, Group, MasterCorporateUser, Stat, User} from "@/interfaces/user";
|
||||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
import {dateSorter} from "@/utils";
|
import {dateSorter} from "@/utils";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { useEffect, useState } from "react";
|
import {useEffect, useMemo, useState} from "react";
|
||||||
import {
|
import {
|
||||||
BsArrowLeft,
|
BsArrowLeft,
|
||||||
BsArrowRepeat,
|
BsArrowRepeat,
|
||||||
@@ -46,54 +46,51 @@ import ProgressBar from "@/components/Low/ProgressBar";
|
|||||||
import AssignmentCreator from "./AssignmentCreator";
|
import AssignmentCreator from "./AssignmentCreator";
|
||||||
import AssignmentView from "./AssignmentView";
|
import AssignmentView from "./AssignmentView";
|
||||||
import {getUserCorporate} from "@/utils/groups";
|
import {getUserCorporate} from "@/utils/groups";
|
||||||
|
import {checkAccess} from "@/utils/permissions";
|
||||||
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments";
|
||||||
|
import AssignmentsPage from "./views/AssignmentsPage";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
|
linkedCorporate?: CorporateUser | MasterCorporateUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TeacherDashboard({ user }: Props) {
|
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(user.id);
|
const {permissions} = usePermissions(user.id);
|
||||||
const {
|
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id});
|
||||||
assignments,
|
|
||||||
isLoading: isAssignmentsLoading,
|
const {users: students, total: totalStudents, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(studentHash);
|
||||||
reload: reloadAssignments,
|
|
||||||
} = useAssignments({ assigner: user.id });
|
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(() => {
|
||||||
setShowModal(!!selectedUser && page === "");
|
setShowModal(!!selectedUser && router.asPath === "/#");
|
||||||
}, [selectedUser, page]);
|
}, [selectedUser, router.asPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
||||||
getUserCorporate(user.id).then(setCorporateUserToShow);
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
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 UserDisplay = (displayUser: User) => (
|
const UserDisplay = (displayUser: User) => (
|
||||||
<div
|
<div
|
||||||
onClick={() => setSelectedUser(displayUser)}
|
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"
|
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" />
|
||||||
<img
|
|
||||||
src={displayUser.profilePicture}
|
|
||||||
alt={displayUser.name}
|
|
||||||
className="rounded-full w-10 h-10"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col gap-1 items-start">
|
<div className="flex flex-col gap-1 items-start">
|
||||||
<span>{displayUser.name}</span>
|
<span>{displayUser.name}</span>
|
||||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||||
@@ -101,53 +98,19 @@ 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) =>
|
const filter = (x: Group) => x.admin === user.id;
|
||||||
x.admin === user.id || x.participants.includes(user.id);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
onClick={() => router.push("/")}
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
>
|
|
||||||
<BsArrowLeft className="text-xl" />
|
<BsArrowLeft className="text-xl" />
|
||||||
<span>Back</span>
|
<span>Back</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold">
|
<h2 className="text-2xl font-semibold">Groups ({groups.filter(filter).length})</h2>
|
||||||
Groups ({groups.filter(filter).length})
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GroupList user={user} />
|
<GroupList user={user} />
|
||||||
@@ -158,19 +121,14 @@ 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,
|
||||||
}))
|
}))
|
||||||
.filter((f) => !!f.focus);
|
.filter((f) => !!f.focus);
|
||||||
const bandScores = formattedStats.map((s) => ({
|
const bandScores = formattedStats.map((s) => ({
|
||||||
module: s.module,
|
module: s.module,
|
||||||
level: calculateBandScore(
|
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
|
||||||
s.score.correct,
|
|
||||||
s.score.total,
|
|
||||||
s.module,
|
|
||||||
s.focus!
|
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const levels: {[key in Module]: number} = {
|
const levels: {[key in Module]: number} = {
|
||||||
@@ -185,260 +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) || 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
|
<div
|
||||||
onClick={() => setPage("")}
|
onClick={() => router.push("/")}
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
>
|
|
||||||
<BsArrowLeft className="text-xl" />
|
<BsArrowLeft className="text-xl" />
|
||||||
<span>Back</span>
|
<span>Back</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<h2 className="text-2xl font-semibold">Students ({total})</h2>
|
||||||
onClick={reloadAssignments}
|
</div>
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
|
||||||
>
|
|
||||||
<span>Reload</span>
|
|
||||||
<BsArrowRepeat
|
|
||||||
className={clsx(
|
|
||||||
"text-xl",
|
|
||||||
isAssignmentsLoading && "animate-spin"
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<h2 className="text-2xl font-semibold">
|
|
||||||
Active Assignments ({assignments.filter(activeFilter).length})
|
|
||||||
</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{assignments.filter(activeFilter).map((a) => (
|
|
||||||
<AssignmentCard
|
|
||||||
{...a}
|
|
||||||
onClick={() => setSelectedAssignment(a)}
|
|
||||||
key={a.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<h2 className="text-2xl font-semibold">
|
|
||||||
Planned Assignments ({assignments.filter(futureFilter).length})
|
|
||||||
</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<div
|
|
||||||
onClick={() => setIsCreatingAssignment(true)}
|
|
||||||
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300"
|
|
||||||
>
|
|
||||||
<BsPlus className="text-6xl" />
|
|
||||||
<span className="text-lg">New Assignment</span>
|
|
||||||
</div>
|
|
||||||
{assignments.filter(futureFilter).map((a) => (
|
|
||||||
<AssignmentCard
|
|
||||||
{...a}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedAssignment(a);
|
|
||||||
setIsCreatingAssignment(true);
|
|
||||||
}}
|
|
||||||
key={a.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<h2 className="text-2xl font-semibold">
|
|
||||||
Past Assignments ({assignments.filter(pastFilter).length})
|
|
||||||
</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{assignments.filter(pastFilter).map((a) => (
|
|
||||||
<AssignmentCard
|
|
||||||
{...a}
|
|
||||||
onClick={() => setSelectedAssignment(a)}
|
|
||||||
key={a.id}
|
|
||||||
allowDownload
|
|
||||||
reload={reloadAssignments}
|
|
||||||
allowArchive
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<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}
|
|
||||||
onClick={() => setSelectedAssignment(a)}
|
|
||||||
key={a.id}
|
|
||||||
allowDownload
|
|
||||||
reload={reloadAssignments}
|
|
||||||
allowUnarchive
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
if (router.asPath === "/#assignments")
|
||||||
|
return (
|
||||||
const DefaultDashboard = () => (
|
<AssignmentsPage
|
||||||
<>
|
assignments={assignments}
|
||||||
{corporateUserToShow && (
|
groups={assignmentsGroups}
|
||||||
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
|
user={user}
|
||||||
Linked to:{" "}
|
reloadAssignments={reloadAssignments}
|
||||||
<b>
|
isLoading={isAssignmentsLoading}
|
||||||
{corporateUserToShow?.corporateInformation?.companyInformation
|
onBack={() => router.push("/")}
|
||||||
.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"
|
|
||||||
/>
|
|
||||||
<IconCard
|
|
||||||
Icon={BsPeople}
|
|
||||||
label="Groups"
|
|
||||||
value={groups.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"
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
{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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
|
if (router.asPath === "/#groups") return <GroupsList />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -450,17 +184,46 @@ 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 === "corporate" || selectedUser.type === "teacher"
|
||||||
selectedUser.type === "teacher"
|
? () => {
|
||||||
? () => setPage("students")
|
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
|
: undefined
|
||||||
}
|
}
|
||||||
onViewTeachers={
|
onViewTeachers={
|
||||||
selectedUser.type === "corporate"
|
selectedUser.type === "corporate" || selectedUser.type === "student"
|
||||||
? () => setPage("teachers")
|
? () => {
|
||||||
|
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
|
: undefined
|
||||||
}
|
}
|
||||||
user={selectedUser}
|
user={selectedUser}
|
||||||
@@ -469,10 +232,95 @@ export default function TeacherDashboard({ user }: Props) {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
</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={user}
|
||||||
|
isCreating={isCreatingAssignment}
|
||||||
|
cancelCreation={() => {
|
||||||
|
setIsCreatingAssignment(false);
|
||||||
|
setSelectedAssignment(undefined);
|
||||||
|
reloadAssignments();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="w-full flex justify-between items-center">
|
||||||
|
<div
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={reloadAssignments}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<span>Reload</span>
|
||||||
|
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-lg font-bold">Active Assignments Status</span>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span>
|
||||||
|
<b>Total:</b> {assignments.filter(activeAssignmentFilter).reduce((acc, curr) => acc + curr.results.length, 0)}/
|
||||||
|
{assignments.filter(activeAssignmentFilter).reduce((acc, curr) => curr.exams.length + acc, 0)}
|
||||||
|
</span>
|
||||||
|
{Object.keys(groupBy(corporateAssignments, (x) => x.corporate?.id)).map((x) => (
|
||||||
|
<div key={x}>
|
||||||
|
<span className="font-semibold">{getUserCompanyName(users.find((u) => u.id === x)!, users, groups)}: </span>
|
||||||
|
<span>
|
||||||
|
{groupBy(corporateAssignments, (x) => x.corporate?.id)[x].reduce((acc, curr) => curr.results.length + acc, 0)}/
|
||||||
|
{groupBy(corporateAssignments, (x) => x.corporate?.id)[x].reduce((acc, curr) => curr.exams.length + acc, 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeAssignmentFilter).length})</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{assignments.filter(activeAssignmentFilter).map((a) => (
|
||||||
|
<AssignmentCard {...a} users={users} onClick={() => setSelectedAssignment(a)} key={a.id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureAssignmentFilter).length})</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<div
|
||||||
|
onClick={() => setIsCreatingAssignment(true)}
|
||||||
|
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
|
||||||
|
<BsPlus className="text-6xl" />
|
||||||
|
<span className="text-lg">New Assignment</span>
|
||||||
|
</div>
|
||||||
|
{assignments.filter(futureAssignmentFilter).map((a) => (
|
||||||
|
<AssignmentCard
|
||||||
|
{...a}
|
||||||
|
users={users}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedAssignment(a);
|
||||||
|
setIsCreatingAssignment(true);
|
||||||
|
}}
|
||||||
|
key={a.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastAssignmentFilter).length})</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{assignments.filter(pastAssignmentFilter).map((a) => (
|
||||||
|
<AssignmentCard
|
||||||
|
{...a}
|
||||||
|
users={users}
|
||||||
|
onClick={() => setSelectedAssignment(a)}
|
||||||
|
key={a.id}
|
||||||
|
allowDownload
|
||||||
|
reload={reloadAssignments}
|
||||||
|
allowArchive
|
||||||
|
allowExcelDownload
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-2xl font-semibold">Assignments start expired ({assignmentsPastExpiredStart.length})</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{assignments.filter(startHasExpiredAssignmentFilter).map((a) => (
|
||||||
|
<AssignmentCard
|
||||||
|
{...a}
|
||||||
|
users={users}
|
||||||
|
onClick={() => setSelectedAssignment(a)}
|
||||||
|
key={a.id}
|
||||||
|
allowDownload
|
||||||
|
reload={reloadAssignments}
|
||||||
|
allowArchive
|
||||||
|
allowExcelDownload
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-2xl font-semibold">Archived Assignments ({assignments.filter(archivedAssignmentFilter).length})</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{assignments.filter(archivedAssignmentFilter).map((a) => (
|
||||||
|
<AssignmentCard
|
||||||
|
{...a}
|
||||||
|
users={users}
|
||||||
|
onClick={() => setSelectedAssignment(a)}
|
||||||
|
key={a.id}
|
||||||
|
allowDownload
|
||||||
|
reload={reloadAssignments}
|
||||||
|
allowUnarchive
|
||||||
|
allowExcelDownload
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,13 +4,14 @@ import {moduleResultText} from "@/constants/ielts";
|
|||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {calculateBandScore} from "@/utils/score";
|
import {calculateBandScore, getGradingLabel} from "@/utils/score";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {Fragment, useEffect, useState} from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
import {
|
import {
|
||||||
BsArrowCounterclockwise,
|
BsArrowCounterclockwise,
|
||||||
|
BsBan,
|
||||||
BsBook,
|
BsBook,
|
||||||
BsClipboard,
|
BsClipboard,
|
||||||
BsClipboardFill,
|
BsClipboardFill,
|
||||||
@@ -26,6 +27,8 @@ import {capitalize} from "lodash";
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import {UserSolution} from "@/interfaces/exam";
|
import {UserSolution} from "@/interfaces/exam";
|
||||||
import ai_usage from "@/utils/ai.detection";
|
import ai_usage from "@/utils/ai.detection";
|
||||||
|
import useGradingSystem from "@/hooks/useGrading";
|
||||||
|
import {Assignment} from "@/interfaces/results";
|
||||||
|
|
||||||
interface Score {
|
interface Score {
|
||||||
module: Module;
|
module: Module;
|
||||||
@@ -44,17 +47,18 @@ interface Props {
|
|||||||
};
|
};
|
||||||
solutions: UserSolution[];
|
solutions: UserSolution[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
assignment?: Assignment;
|
||||||
onViewResults: (moduleIndex?: number) => void;
|
onViewResults: (moduleIndex?: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Finish({user, scores, modules, information, solutions, isLoading, onViewResults}: Props) {
|
export default function Finish({user, scores, modules, information, solutions, isLoading, assignment, onViewResults}: Props) {
|
||||||
const [selectedModule, setSelectedModule] = useState(modules[0]);
|
const [selectedModule, setSelectedModule] = useState(modules[0]);
|
||||||
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
|
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
|
||||||
const [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false);
|
const [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false);
|
||||||
|
|
||||||
const aiUsage = Math.round(ai_usage(solutions) * 100);
|
const aiUsage = Math.round(ai_usage(solutions) * 100);
|
||||||
|
|
||||||
const exams = useExamStore((state) => state.exams);
|
const exams = useExamStore((state) => state.exams);
|
||||||
|
const {gradingSystem} = useGradingSystem();
|
||||||
|
|
||||||
useEffect(() => setSelectedScore(scores.find((x) => x.module === selectedModule)!), [scores, selectedModule]);
|
useEffect(() => setSelectedScore(scores.find((x) => x.module === selectedModule)!), [scores, selectedModule]);
|
||||||
|
|
||||||
@@ -94,10 +98,10 @@ export default function Finish({user, scores, modules, information, solutions, i
|
|||||||
|
|
||||||
const showLevel = (level: number) => {
|
const showLevel = (level: number) => {
|
||||||
if (selectedModule === "level") {
|
if (selectedModule === "level") {
|
||||||
const [levelStr, grade] = getLevelScore(level);
|
const label = getGradingLabel(level, gradingSystem?.steps || []);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-1">
|
<div className="flex flex-col items-center justify-center gap-1">
|
||||||
<span className="text-xl font-bold">{levelStr}</span>
|
<span className="text-xl font-bold">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -165,13 +169,11 @@ export default function Finish({user, scores, modules, information, solutions, i
|
|||||||
<span className="font-semibold">Writing</span>
|
<span className="font-semibold">Writing</span>
|
||||||
</div>
|
</div>
|
||||||
{aiUsage >= 50 && user.type !== "student" && (
|
{aiUsage >= 50 && user.type !== "student" && (
|
||||||
<div className={clsx(
|
<div
|
||||||
"flex items-center justify-center border px-3 h-full rounded",
|
className={clsx("flex items-center justify-center border px-3 h-full rounded", {
|
||||||
{
|
"bg-orange-100 border-orange-400 text-orange-700": aiUsage < 80,
|
||||||
'bg-orange-100 border-orange-400 text-orange-700': aiUsage < 80,
|
"bg-red-100 border-red-400 text-red-700": aiUsage >= 80,
|
||||||
'bg-red-100 border-red-400 text-red-700': aiUsage >= 80,
|
})}>
|
||||||
}
|
|
||||||
)}>
|
|
||||||
<span className="text-xs">AI Usage</span>
|
<span className="text-xs">AI Usage</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -210,7 +212,18 @@ export default function Finish({user, scores, modules, information, solutions, i
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isLoading && (
|
{assignment && !assignment.released && !isLoading && (
|
||||||
|
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-12">
|
||||||
|
{/* <span className={clsx("loading loading-infinity w-32", moduleColors[selectedModule].progress)} /> */}
|
||||||
|
<BsBan size={64} className={clsx(moduleColors[selectedModule].progress)} />
|
||||||
|
<span className={clsx("text-center text-2xl font-bold", moduleColors[selectedModule].progress)}>
|
||||||
|
This exam has not yet been released by its assigner.
|
||||||
|
<br />
|
||||||
|
You can check it later on your records page when it is released!
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && !(assignment && !assignment.released) && (
|
||||||
<div className="mb-20 mt-32 flex w-full items-center justify-between gap-9">
|
<div className="mb-20 mt-32 flex w-full items-center justify-between gap-9">
|
||||||
<span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span>
|
<span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span>
|
||||||
<div className="flex gap-9 px-16">
|
<div className="flex gap-9 px-16">
|
||||||
@@ -274,7 +287,9 @@ 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={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
disabled={user.type === "admin"}
|
// disabled={user.type === "admin"}
|
||||||
|
// TODO: temporarily disabled
|
||||||
|
disabled
|
||||||
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||||
<BsArrowCounterclockwise className="h-7 w-7 text-white" />
|
<BsArrowCounterclockwise className="h-7 w-7 text-white" />
|
||||||
</button>
|
</button>
|
||||||
@@ -283,6 +298,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 +306,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,221 +0,0 @@
|
|||||||
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
|
||||||
import {renderExercise} from "@/components/Exercises";
|
|
||||||
import Button from "@/components/Low/Button";
|
|
||||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
|
||||||
import {renderSolution} from "@/components/Solutions";
|
|
||||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
|
||||||
import {LevelExam, LevelPart, UserSolution, WritingExam} from "@/interfaces/exam";
|
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import {defaultUserSolutions} from "@/utils/exams";
|
|
||||||
import {countExercises} from "@/utils/moduleUtils";
|
|
||||||
import {mdiArrowRight} from "@mdi/js";
|
|
||||||
import Icon from "@mdi/react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import {Fragment, useEffect, useState} from "react";
|
|
||||||
import {BsChevronDown, BsChevronUp} from "react-icons/bs";
|
|
||||||
import {toast} from "react-toastify";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
exam: LevelExam;
|
|
||||||
showSolutions?: boolean;
|
|
||||||
onFinish: (userSolutions: UserSolution[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TextComponent({part}: {part: LevelPart}) {
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
|
||||||
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
|
|
||||||
const [showBlankModal, setShowBlankModal] = useState(false);
|
|
||||||
|
|
||||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
|
||||||
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
|
||||||
const {partIndex, setPartIndex} = useExamStore((state) => state);
|
|
||||||
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
|
||||||
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]);
|
|
||||||
|
|
||||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasExamEnded && exerciseIndex === -1) {
|
|
||||||
setExerciseIndex(exerciseIndex + 1);
|
|
||||||
}
|
|
||||||
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
|
||||||
|
|
||||||
const confirmFinishModule = (keepGoing?: boolean) => {
|
|
||||||
if (!keepGoing) {
|
|
||||||
setShowBlankModal(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onFinish(userSolutions);
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
|
||||||
scrollToTop();
|
|
||||||
if (solution) {
|
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (storeQuestionIndex > 0) {
|
|
||||||
const exercise = getExercise();
|
|
||||||
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: storeQuestionIndex}]);
|
|
||||||
}
|
|
||||||
setStoreQuestionIndex(0);
|
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
|
||||||
setExerciseIndex(exerciseIndex + 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
|
||||||
setPartIndex(partIndex + 1);
|
|
||||||
setExerciseIndex(!!exam.parts[partIndex + 1].context ? -1 : 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
solution &&
|
|
||||||
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
|
|
||||||
(x) => x === 0,
|
|
||||||
) &&
|
|
||||||
!showSolutions &&
|
|
||||||
!hasExamEnded
|
|
||||||
) {
|
|
||||||
setShowBlankModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasExamEnded(false);
|
|
||||||
|
|
||||||
if (solution) {
|
|
||||||
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
|
|
||||||
} else {
|
|
||||||
onFinish(userSolutions);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
|
||||||
scrollToTop();
|
|
||||||
if (solution) {
|
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (storeQuestionIndex > 0) {
|
|
||||||
const exercise = getExercise();
|
|
||||||
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: storeQuestionIndex}]);
|
|
||||||
}
|
|
||||||
setStoreQuestionIndex(0);
|
|
||||||
|
|
||||||
setExerciseIndex(exerciseIndex - 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getExercise = () => {
|
|
||||||
const exercise = exam.parts[partIndex].exercises[exerciseIndex];
|
|
||||||
return {
|
|
||||||
...exercise,
|
|
||||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateExerciseIndex = () => {
|
|
||||||
if (partIndex === 0)
|
|
||||||
return (
|
|
||||||
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex + multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
|
|
||||||
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) => acc + curr.amount, 0)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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="flex flex-col w-full gap-2">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<TextComponent part={exam.parts[partIndex]} />
|
|
||||||
</>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col h-full w-full gap-8 items-center">
|
|
||||||
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
|
||||||
<ModuleTitle
|
|
||||||
minTimer={exam.minTimer}
|
|
||||||
exerciseIndex={calculateExerciseIndex()}
|
|
||||||
module="level"
|
|
||||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
|
||||||
disableTimer={showSolutions}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"mb-20 w-full",
|
|
||||||
partIndex > -1 && exerciseIndex > -1 && !!exam.parts[partIndex].context && "grid grid-cols-2 gap-4",
|
|
||||||
)}>
|
|
||||||
{partIndex > -1 && !!exam.parts[partIndex].context && renderText()}
|
|
||||||
|
|
||||||
{exerciseIndex > -1 &&
|
|
||||||
partIndex > -1 &&
|
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
|
||||||
!showSolutions &&
|
|
||||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
|
|
||||||
|
|
||||||
{exerciseIndex > -1 &&
|
|
||||||
partIndex > -1 &&
|
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
|
||||||
showSolutions &&
|
|
||||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
|
|
||||||
</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">
|
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
38
src/exams/Level/PartDivider.tsx
Normal file
38
src/exams/Level/PartDivider.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import { LevelPart, UserSolution } from "@/interfaces/exam";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
partIndex: number;
|
||||||
|
part: LevelPart // for now
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PartDivider: React.FC<Props> = ({ partIndex, part, onNext }) => {
|
||||||
|
|
||||||
|
const moduleIcon: { [key in Module]: ReactNode } = {
|
||||||
|
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
|
||||||
|
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
|
||||||
|
writing: <BsPen className="text-ielts-writing w-6 h-6" />,
|
||||||
|
speaking: <BsMegaphone className="text-ielts-speaking w-6 h-6" />,
|
||||||
|
level: <BsClipboard className="text-white w-6 h-6" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 */}
|
||||||
|
<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 && 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">
|
||||||
|
<Button color="purple" onClick={() => onNext()} className="max-w-[200px] self-end w-full text-2xl">
|
||||||
|
{partIndex === 0 ? `Start now`: `Start Part ${partIndex + 1}`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PartDivider;
|
||||||
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
|
||||||
|
);
|
||||||
|
}
|
||||||
191
src/exams/Level/TextComponent.tsx
Normal file
191
src/exams/Level/TextComponent.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { LevelPart } from "@/interfaces/exam";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
part: LevelPart,
|
||||||
|
contextWords: { match: string, originalLine: string }[] | undefined,
|
||||||
|
setContextWordLines: React.Dispatch<React.SetStateAction<number[] | undefined>>
|
||||||
|
setTotalLines: React.Dispatch<React.SetStateAction<number>>
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextComponent: React.FC<Props> = ({ part, contextWords, setContextWordLines, setTotalLines }) => {
|
||||||
|
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 = () => {
|
||||||
|
if (textRef.current) {
|
||||||
|
const computedStyle = window.getComputedStyle(textRef.current);
|
||||||
|
const lineHeightValue = parseFloat(computedStyle.lineHeight);
|
||||||
|
const containerWidth = textRef.current.clientWidth;
|
||||||
|
setLineHeight(lineHeightValue);
|
||||||
|
|
||||||
|
const offscreenElement = document.createElement('div');
|
||||||
|
offscreenElement.style.position = 'absolute';
|
||||||
|
offscreenElement.style.top = '-9999px';
|
||||||
|
offscreenElement.style.left = '-9999px';
|
||||||
|
offscreenElement.style.whiteSpace = 'pre-wrap';
|
||||||
|
offscreenElement.style.width = `${containerWidth}px`;
|
||||||
|
offscreenElement.style.font = computedStyle.font;
|
||||||
|
offscreenElement.style.lineHeight = computedStyle.lineHeight;
|
||||||
|
offscreenElement.style.wordWrap = 'break-word';
|
||||||
|
offscreenElement.style.textAlign = computedStyle.textAlign as CanvasTextAlign;
|
||||||
|
|
||||||
|
const textContent = textRef.current.textContent || '';
|
||||||
|
|
||||||
|
const paragraphs = textContent.split(/\n\n/);
|
||||||
|
const betweenParagraphs: string[][] = Array.from({ length: paragraphs.length }, () => []);
|
||||||
|
|
||||||
|
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');
|
||||||
|
if (wordIndex === 0 && bTag) {
|
||||||
|
const b = document.createElement('b');
|
||||||
|
b.classList.add(bTag.className);
|
||||||
|
b.textContent = `${lineIndex + 1}`;
|
||||||
|
span.appendChild(b);
|
||||||
|
span.appendChild(document.createTextNode(word.substring(1)));
|
||||||
|
}else {
|
||||||
|
span.appendChild(document.createTextNode(word));
|
||||||
|
}
|
||||||
|
return span;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
lines.forEach(line => {
|
||||||
|
line.forEach((span, index) => {
|
||||||
|
offscreenElement.appendChild(span);
|
||||||
|
});
|
||||||
|
offscreenElement.appendChild(document.createElement('br'));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(offscreenElement);
|
||||||
|
|
||||||
|
const processedLines: string[][] = [[]];
|
||||||
|
let currentLine = 1;
|
||||||
|
let currentLineTop: number | undefined;
|
||||||
|
|
||||||
|
let contextWordLines: number[] = [];
|
||||||
|
if (contextWords) {
|
||||||
|
contextWordLines = Array(contextWords.length).fill(-1);
|
||||||
|
}
|
||||||
|
const firstChild = offscreenElement.firstChild as HTMLElement;
|
||||||
|
if (firstChild) {
|
||||||
|
currentLineTop = firstChild.getBoundingClientRect().top;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spans = offscreenElement.querySelectorAll<HTMLSpanElement>('span');
|
||||||
|
|
||||||
|
let betweenIndex = 0;
|
||||||
|
const addBreaksTo: number[] = [];
|
||||||
|
spans.forEach((span, index) => {
|
||||||
|
const rect = span.getBoundingClientRect();
|
||||||
|
const top = rect.top;
|
||||||
|
|
||||||
|
if (
|
||||||
|
betweenIndex < paragraphs.length - 1 &&
|
||||||
|
span.textContent === betweenParagraphs[betweenIndex][1] &&
|
||||||
|
spans[index - 1].textContent === betweenParagraphs[betweenIndex][0]
|
||||||
|
) {
|
||||||
|
addBreaksTo.push(currentLine);
|
||||||
|
betweenIndex = betweenIndex + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLineTop !== undefined && top > currentLineTop) {
|
||||||
|
currentLine++;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setAddBreaksTo(addBreaksTo);
|
||||||
|
|
||||||
|
setLineNumbers(processedLines.map((_, index) => index + 1));
|
||||||
|
setTotalLines(currentLine);
|
||||||
|
|
||||||
|
if (contextWordLines.length > 0) {
|
||||||
|
setContextWordLines(contextWordLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.removeChild(offscreenElement);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
calculateLineNumbers();
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
calculateLineNumbers();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (textRef.current) {
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
resizeObserver.observe(textRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (textRef.current) {
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
resizeObserver.unobserve(textRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [part.context, contextWords]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex mt-2">
|
||||||
|
<div className="flex-shrink-0 w-8 pr-2">
|
||||||
|
{lineNumbers.map(num => (
|
||||||
|
<>
|
||||||
|
<div key={num} className="text-gray-400 flex justify-end" style={{ lineHeight: `${lineHeight}px` }}>
|
||||||
|
{num}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TextComponent;
|
||||||
529
src/exams/Level/index.tsx
Normal file
529
src/exams/Level/index.tsx
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
import QuestionsModal from "@/components/QuestionsModal";
|
||||||
|
import { renderExercise } from "@/components/Exercises";
|
||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
|
import { renderSolution } from "@/components/Solutions";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import { Exercise, FillBlanksMCOption, LevelExam, MultipleChoiceExercise, UserSolution } from "@/interfaces/exam";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import { countExercises } from "@/utils/moduleUtils";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { use, useEffect, useMemo, useState } from "react";
|
||||||
|
import TextComponent from "./TextComponent";
|
||||||
|
import PartDivider from "./PartDivider";
|
||||||
|
import Timer from "@/components/Medium/Timer";
|
||||||
|
import shuffleExamExercise from "./Shuffle";
|
||||||
|
import { Tab } from "@headlessui/react";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
exam: LevelExam;
|
||||||
|
showSolutions?: boolean;
|
||||||
|
onFinish: (userSolutions: UserSolution[]) => void;
|
||||||
|
editing?: boolean;
|
||||||
|
partDividers?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
|
||||||
|
return Array.isArray(words) && words.every(
|
||||||
|
word => word && typeof word === 'object' && 'id' in word && 'options' in word
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default function Level({ exam, showSolutions = false, onFinish, editing = false }: Props) {
|
||||||
|
const levelBgColor = "bg-ielts-level-light";
|
||||||
|
|
||||||
|
const {
|
||||||
|
userSolutions,
|
||||||
|
hasExamEnded,
|
||||||
|
partIndex,
|
||||||
|
exerciseIndex,
|
||||||
|
questionIndex,
|
||||||
|
shuffles,
|
||||||
|
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 [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 [contextWords, setContextWords] = useState<{ match: string, originalLine: string }[] | 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(() => {
|
||||||
|
if (typeof currentSolution !== "undefined") {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentSolution, exam.id, exam.shuffle, shuffles, currentExercise])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof currentSolution !== "undefined") {
|
||||||
|
setCurrentSolution(undefined);
|
||||||
|
}
|
||||||
|
// 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 = () => {
|
||||||
|
let exercise = exam.parts[partIndex]?.exercises[exerciseIndex];
|
||||||
|
exercise = {
|
||||||
|
...exercise,
|
||||||
|
userSolutions: userSolutions.find((x) => x.exercise == exercise.id)?.solutions || [],
|
||||||
|
};
|
||||||
|
exercise = shuffleExamExercise(exam.shuffle, exercise, showSolutions, userSolutions, shuffles, setShuffles);
|
||||||
|
return exercise;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentExercise(getExercise());
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [partIndex, exerciseIndex, questionIndex]);
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
|
setNextExerciseCalled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextExercise = () => {
|
||||||
|
scrollToTop();
|
||||||
|
|
||||||
|
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||||
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
|
setCurrentSolutionSet(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partIndex + 1 === exam.parts.length && !hasExamEnded && !showQuestionsModal && !showSolutions && !continueAnyways) {
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
setExerciseIndex(0);
|
||||||
|
setQuestionIndex(0);
|
||||||
|
setCurrentSolutionSet(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partIndex + 1 === exam.parts.length && exerciseIndex === exam.parts[partIndex].exercises.length - 1 && !continueAnyways) {
|
||||||
|
modalKwargs();
|
||||||
|
setShowQuestionsModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasExamEnded(false);
|
||||||
|
setCurrentSolutionSet(false);
|
||||||
|
if (typeof showSolutionsSave !== "undefined") {
|
||||||
|
onFinish(showSolutionsSave);
|
||||||
|
} else {
|
||||||
|
onFinish(userSolutions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (nextExerciseCalled && currentSolutionSet) {
|
||||||
|
nextExercise();
|
||||||
|
setNextExerciseCalled(false);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [nextExerciseCalled, currentSolutionSet])
|
||||||
|
|
||||||
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
|
scrollToTop();
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (exerciseIndex - 1 === -1) {
|
||||||
|
setPartIndex(partIndex - 1);
|
||||||
|
const lastPartExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1;
|
||||||
|
const previousExercise = exam.parts[partIndex - 1].exercises[lastPartExerciseIndex];
|
||||||
|
if (previousExercise.type === "multipleChoice") {
|
||||||
|
setQuestionIndex(previousExercise.questions.length - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateExerciseIndex = () => {
|
||||||
|
return exam.parts.reduce((acc, curr, index) => {
|
||||||
|
if (index < partIndex) {
|
||||||
|
return acc + countExercises(curr.exercises)
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, 0) + (questionIndex + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
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="flex flex-col w-full gap-2">
|
||||||
|
{textRender && !textRenderDisabled ? (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const partLabel = () => {
|
||||||
|
const partCategory = exam.parts[partIndex].category ? ` (${exam.parts[partIndex].category})` : '';
|
||||||
|
if (currentExercise?.type === "fillBlanks" && typeCheckWordsMC(currentExercise.words))
|
||||||
|
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") {
|
||||||
|
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") {
|
||||||
|
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})${partCategory}\n\n${nextExercise.prompt}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const answeredEveryQuestion = (partIndex: number) => {
|
||||||
|
return exam.parts[partIndex].exercises.every((exercise) => {
|
||||||
|
const userSolution = userSolutions.find(x => x.exercise === exercise.id);
|
||||||
|
switch (exercise.type) {
|
||||||
|
case 'multipleChoice':
|
||||||
|
return userSolution?.solutions.length === exercise.questions.length;
|
||||||
|
case 'fillBlanks':
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
|
||||||
|
|
||||||
|
const findMatch = (index: number) => {
|
||||||
|
if (currentExercise && currentExercise.type === "multipleChoice" && currentExercise!.questions[index]) {
|
||||||
|
const match = currentExercise!.questions[index].prompt.match(regex);
|
||||||
|
if (match) {
|
||||||
|
return { match: match[1], originalLine: match[2] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the client for some whatever random reason decides
|
||||||
|
// to add more questions update this
|
||||||
|
const numberOfQuestions = 2;
|
||||||
|
|
||||||
|
if (exam.parts[partIndex].context) {
|
||||||
|
const hits = Array.from({ length: numberOfQuestions }).reduce<{ match: string, originalLine: string }[]>((acc, _, i) => {
|
||||||
|
const result = findMatch(questionIndex + i);
|
||||||
|
if (!!result) {
|
||||||
|
acc.push(result);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (hits.length > 0) {
|
||||||
|
setContextWords(hits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentExercise, questionIndex, totalLines]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
exerciseIndex !== -1 && currentExercise &&
|
||||||
|
currentExercise.type === "multipleChoice" &&
|
||||||
|
exam.parts[partIndex].context && contextWordLines
|
||||||
|
) {
|
||||||
|
if (contextWordLines.length > 0) {
|
||||||
|
contextWordLines.forEach((n, i) => {
|
||||||
|
if (contextWords && contextWords[i] && n !== -1) {
|
||||||
|
const updatedPrompt = currentExercise!.questions[questionIndex + i].prompt.replace(
|
||||||
|
`in line ${contextWords[i].originalLine}`,
|
||||||
|
`in line ${n}`
|
||||||
|
);
|
||||||
|
currentExercise!.questions[questionIndex + i].prompt = updatedPrompt;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setChangedPrompt(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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 (
|
||||||
|
<>
|
||||||
|
<div className={clsx("flex flex-col h-full w-full gap-8 items-center", showPartDivider && "justify-center")}>
|
||||||
|
<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 && questionIndex === 0 && (showPartDivider || startNow)) &&
|
||||||
|
<Timer minTimer={exam.minTimer} disableTimer={showSolutions} standalone={true} />
|
||||||
|
}
|
||||||
|
{(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
|
||||||
|
examLabel={exam.label}
|
||||||
|
partLabel={partLabel()}
|
||||||
|
minTimer={exam.minTimer}
|
||||||
|
exerciseIndex={calculateExerciseIndex()}
|
||||||
|
module="level"
|
||||||
|
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||||
|
disableTimer={showSolutions || editing}
|
||||||
|
showTimer={false}
|
||||||
|
{...mcNavKwargs}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"mb-20 w-full",
|
||||||
|
!!exam.parts[partIndex].context && !textRender && "grid grid-cols-2 gap-4",
|
||||||
|
)}>
|
||||||
|
{memoizedRender}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import {renderSolution} from "@/components/Solutions";
|
|||||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
import AudioPlayer from "@/components/Low/AudioPlayer";
|
import AudioPlayer from "@/components/Low/AudioPlayer";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
import BlankQuestionsModal from "@/components/QuestionsModal";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {countExercises} from "@/utils/moduleUtils";
|
import {countExercises} from "@/utils/moduleUtils";
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import ProgressBar from "@/components/Low/ProgressBar";
|
|||||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
import {Divider} from "primereact/divider";
|
import {Divider} from "primereact/divider";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
import BlankQuestionsModal from "@/components/QuestionsModal";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {defaultUserSolutions} from "@/utils/exams";
|
import {defaultUserSolutions} from "@/utils/exams";
|
||||||
import {countExercises} from "@/utils/moduleUtils";
|
import {countExercises} from "@/utils/moduleUtils";
|
||||||
|
|||||||
@@ -2,27 +2,17 @@
|
|||||||
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 {
|
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";
|
|
||||||
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";
|
||||||
import {capitalize} from "lodash";
|
import {capitalize} from "lodash";
|
||||||
import ProfileSummary from "@/components/ProfileSummary";
|
import ProfileSummary from "@/components/ProfileSummary";
|
||||||
import { Variant } from "@/interfaces/exam";
|
import {ShuffleMap, Shuffles, Variant} from "@/interfaces/exam";
|
||||||
import useSessions, {Session} from "@/hooks/useSessions";
|
import useSessions, {Session} from "@/hooks/useSessions";
|
||||||
import SessionCard from "@/components/Medium/SessionCard";
|
import SessionCard from "@/components/Medium/SessionCard";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
@@ -31,37 +21,27 @@ import moment from "moment";
|
|||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
page: "exercises" | "exams";
|
page: "exercises" | "exams";
|
||||||
onStart: (
|
onStart: (modules: Module[], avoidRepeated: boolean, variant: Variant) => void;
|
||||||
modules: Module[],
|
|
||||||
avoidRepeated: boolean,
|
|
||||||
variant: Variant,
|
|
||||||
) => void;
|
|
||||||
disableSelection?: boolean;
|
disableSelection?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Selection({
|
export default function Selection({user, page, onStart, disableSelection = false}: Props) {
|
||||||
user,
|
|
||||||
page,
|
|
||||||
onStart,
|
|
||||||
disableSelection = false,
|
|
||||||
}: Props) {
|
|
||||||
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
||||||
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);
|
||||||
|
|
||||||
const toggleModule = (module: Module) => {
|
const toggleModule = (module: Module) => {
|
||||||
const modules = selectedModules.filter((x) => x !== module);
|
const modules = selectedModules.filter((x) => x !== module);
|
||||||
setSelectedModules((prev) =>
|
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
|
||||||
prev.includes(module) ? modules : [...modules, module],
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSession = async (session: Session) => {
|
const loadSession = async (session: Session) => {
|
||||||
|
state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []})));
|
||||||
state.setSelectedModules(session.selectedModules);
|
state.setSelectedModules(session.selectedModules);
|
||||||
state.setExam(session.exam);
|
state.setExam(session.exam);
|
||||||
state.setExams(session.exams);
|
state.setExams(session.exams);
|
||||||
@@ -84,41 +64,31 @@ export default function Selection({
|
|||||||
user={user}
|
user={user}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
icon: (
|
icon: <BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />,
|
||||||
<BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />
|
|
||||||
),
|
|
||||||
label: "Reading",
|
label: "Reading",
|
||||||
value: totalExamsByModule(stats, "reading"),
|
value: totalExamsByModule(stats, "reading"),
|
||||||
tooltip: "The amount of reading exams performed.",
|
tooltip: "The amount of reading exams performed.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: (
|
icon: <BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />,
|
||||||
<BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />
|
|
||||||
),
|
|
||||||
label: "Listening",
|
label: "Listening",
|
||||||
value: totalExamsByModule(stats, "listening"),
|
value: totalExamsByModule(stats, "listening"),
|
||||||
tooltip: "The amount of listening exams performed.",
|
tooltip: "The amount of listening exams performed.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: (
|
icon: <BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />,
|
||||||
<BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />
|
|
||||||
),
|
|
||||||
label: "Writing",
|
label: "Writing",
|
||||||
value: totalExamsByModule(stats, "writing"),
|
value: totalExamsByModule(stats, "writing"),
|
||||||
tooltip: "The amount of writing exams performed.",
|
tooltip: "The amount of writing exams performed.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: (
|
icon: <BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />,
|
||||||
<BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />
|
|
||||||
),
|
|
||||||
label: "Speaking",
|
label: "Speaking",
|
||||||
value: totalExamsByModule(stats, "speaking"),
|
value: totalExamsByModule(stats, "speaking"),
|
||||||
tooltip: "The amount of speaking exams performed.",
|
tooltip: "The amount of speaking exams performed.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: (
|
icon: <BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />,
|
||||||
<BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />
|
|
||||||
),
|
|
||||||
label: "Level",
|
label: "Level",
|
||||||
value: totalExamsByModule(stats, "level"),
|
value: totalExamsByModule(stats, "level"),
|
||||||
tooltip: "The amount of level exams performed.",
|
tooltip: "The amount of level exams performed.",
|
||||||
@@ -132,35 +102,23 @@ export default function Selection({
|
|||||||
<span className="text-mti-gray-taupe">
|
<span className="text-mti-gray-taupe">
|
||||||
{page === "exercises" && (
|
{page === "exercises" && (
|
||||||
<>
|
<>
|
||||||
In the realm of language acquisition, practice makes perfect,
|
In the realm of language acquisition, practice makes perfect, and our exercises are the key to unlocking your full
|
||||||
and our exercises are the key to unlocking your full potential.
|
potential. Dive into a world of interactive and engaging exercises that cater to diverse learning styles. From grammar
|
||||||
Dive into a world of interactive and engaging exercises that
|
drills that build a strong foundation to vocabulary challenges that broaden your lexicon, our exercises are carefully
|
||||||
cater to diverse learning styles. From grammar drills that build
|
designed to make learning English both enjoyable and effective. Whether you're looking to reinforce specific
|
||||||
a strong foundation to vocabulary challenges that broaden your
|
skills or embark on a holistic language journey, our exercises are your companions in the pursuit of excellence.
|
||||||
lexicon, our exercises are carefully designed to make learning
|
Embrace the joy of learning as you navigate through a variety of activities that cater to every facet of language
|
||||||
English both enjoyable and effective. Whether you're
|
acquisition. Your linguistic adventure starts here!
|
||||||
looking to reinforce specific skills or embark on a holistic
|
|
||||||
language journey, our exercises are your companions in the
|
|
||||||
pursuit of excellence. Embrace the joy of learning as you
|
|
||||||
navigate through a variety of activities that cater to every
|
|
||||||
facet of language acquisition. Your linguistic adventure starts
|
|
||||||
here!
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{page === "exams" && (
|
{page === "exams" && (
|
||||||
<>
|
<>
|
||||||
Welcome to the heart of success on your English language
|
Welcome to the heart of success on your English language journey! Our exams are crafted with precision to assess and
|
||||||
journey! Our exams are crafted with precision to assess and
|
enhance your language skills. Each test is a passport to your linguistic prowess, designed to challenge and elevate
|
||||||
enhance your language skills. Each test is a passport to your
|
your abilities. Whether you're a beginner or a seasoned learner, our exams cater to all levels, providing a
|
||||||
linguistic prowess, designed to challenge and elevate your
|
comprehensive evaluation of your reading, writing, speaking, and listening skills. Prepare to embark on a journey of
|
||||||
abilities. Whether you're a beginner or a seasoned learner,
|
self-discovery and language mastery as you navigate through our thoughtfully curated exams. Your success is not just a
|
||||||
our exams cater to all levels, providing a comprehensive
|
destination; it's a testament to your dedication and our commitment to empowering you with the English language.
|
||||||
evaluation of your reading, writing, speaking, and listening
|
|
||||||
skills. Prepare to embark on a journey of self-discovery and
|
|
||||||
language mastery as you navigate through our thoughtfully
|
|
||||||
curated exams. Your success is not just a destination; it's
|
|
||||||
a testament to your dedication and our commitment to empowering
|
|
||||||
you with the English language.
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -171,26 +129,16 @@ export default function Selection({
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={reload}
|
onClick={reload}
|
||||||
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"
|
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
|
||||||
>
|
<span className="text-mti-black text-lg font-bold">Unfinished Sessions</span>
|
||||||
<span className="text-mti-black text-lg font-bold">
|
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
|
||||||
Unfinished Sessions
|
|
||||||
</span>
|
|
||||||
<BsArrowRepeat
|
|
||||||
className={clsx("text-xl", isLoading && "animate-spin")}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
<span className="text-mti-gray-taupe flex gap-8 overflow-x-auto pb-2">
|
||||||
{sessions
|
{sessions
|
||||||
.sort((a, b) => moment(b.date).diff(moment(a.date)))
|
.sort((a, b) => moment(b.date).diff(moment(a.date)))
|
||||||
.map((session) => (
|
.map((session) => (
|
||||||
<SessionCard
|
<SessionCard session={session} key={session.sessionId} reload={reload} loadSession={loadSession} />
|
||||||
session={session}
|
|
||||||
key={session.sessionId}
|
|
||||||
reload={reload}
|
|
||||||
loadSession={loadSession}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
</section>
|
</section>
|
||||||
@@ -198,170 +146,108 @@ export default function Selection({
|
|||||||
|
|
||||||
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
|
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
|
||||||
<div
|
<div
|
||||||
onClick={
|
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
|
||||||
!disableSelection && !selectedModules.includes("level")
|
|
||||||
? () => toggleModule("reading")
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||||
selectedModules.includes("reading") || disableSelection
|
selectedModules.includes("reading") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||||
? "border-mti-purple-light"
|
)}>
|
||||||
: "border-mti-gray-platinum",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
<div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||||
<BsBook className="h-7 w-7 text-white" />
|
<BsBook className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold">Reading:</span>
|
<span className="font-semibold">Reading:</span>
|
||||||
<p className="text-left text-xs">
|
<p className="text-left text-xs">
|
||||||
Expand your vocabulary, improve your reading comprehension and
|
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
|
||||||
improve your ability to interpret texts in English.
|
|
||||||
</p>
|
</p>
|
||||||
{!selectedModules.includes("reading") &&
|
{!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && (
|
||||||
!selectedModules.includes("level") &&
|
|
||||||
!disableSelection && (
|
|
||||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||||
)}
|
)}
|
||||||
{(selectedModules.includes("reading") || disableSelection) && (
|
{(selectedModules.includes("reading") || disableSelection) && (
|
||||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||||
)}
|
)}
|
||||||
{selectedModules.includes("level") && (
|
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
||||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={
|
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
|
||||||
!disableSelection && !selectedModules.includes("level")
|
|
||||||
? () => toggleModule("listening")
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||||
selectedModules.includes("listening") || disableSelection
|
selectedModules.includes("listening") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||||
? "border-mti-purple-light"
|
)}>
|
||||||
: "border-mti-gray-platinum",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
<div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||||
<BsHeadphones className="h-7 w-7 text-white" />
|
<BsHeadphones className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold">Listening:</span>
|
<span className="font-semibold">Listening:</span>
|
||||||
<p className="text-left text-xs">
|
<p className="text-left text-xs">
|
||||||
Improve your ability to follow conversations in English and your
|
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
|
||||||
ability to understand different accents and intonations.
|
|
||||||
</p>
|
</p>
|
||||||
{!selectedModules.includes("listening") &&
|
{!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && (
|
||||||
!selectedModules.includes("level") &&
|
|
||||||
!disableSelection && (
|
|
||||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||||
)}
|
)}
|
||||||
{(selectedModules.includes("listening") || disableSelection) && (
|
{(selectedModules.includes("listening") || disableSelection) && (
|
||||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||||
)}
|
)}
|
||||||
{selectedModules.includes("level") && (
|
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
||||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={
|
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
|
||||||
!disableSelection && !selectedModules.includes("level")
|
|
||||||
? () => toggleModule("writing")
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||||
selectedModules.includes("writing") || disableSelection
|
selectedModules.includes("writing") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||||
? "border-mti-purple-light"
|
)}>
|
||||||
: "border-mti-gray-platinum",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
<div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||||
<BsPen className="h-7 w-7 text-white" />
|
<BsPen className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold">Writing:</span>
|
<span className="font-semibold">Writing:</span>
|
||||||
<p className="text-left text-xs">
|
<p className="text-left text-xs">
|
||||||
Allow you to practice writing in a variety of formats, from simple
|
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
|
||||||
paragraphs to complex essays.
|
|
||||||
</p>
|
</p>
|
||||||
{!selectedModules.includes("writing") &&
|
{!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && (
|
||||||
!selectedModules.includes("level") &&
|
|
||||||
!disableSelection && (
|
|
||||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||||
)}
|
)}
|
||||||
{(selectedModules.includes("writing") || disableSelection) && (
|
{(selectedModules.includes("writing") || disableSelection) && (
|
||||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||||
)}
|
)}
|
||||||
{selectedModules.includes("level") && (
|
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
||||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={
|
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
|
||||||
!disableSelection && !selectedModules.includes("level")
|
|
||||||
? () => toggleModule("speaking")
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||||
selectedModules.includes("speaking") || disableSelection
|
selectedModules.includes("speaking") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||||
? "border-mti-purple-light"
|
)}>
|
||||||
: "border-mti-gray-platinum",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
<div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||||
<BsMegaphone className="h-7 w-7 text-white" />
|
<BsMegaphone className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold">Speaking:</span>
|
<span className="font-semibold">Speaking:</span>
|
||||||
<p className="text-left text-xs">
|
<p className="text-left text-xs">
|
||||||
You'll have access to interactive dialogs, pronunciation
|
You'll have access to interactive dialogs, pronunciation exercises and speech recordings.
|
||||||
exercises and speech recordings.
|
|
||||||
</p>
|
</p>
|
||||||
{!selectedModules.includes("speaking") &&
|
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && (
|
||||||
!selectedModules.includes("level") &&
|
|
||||||
!disableSelection && (
|
|
||||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||||
)}
|
)}
|
||||||
{(selectedModules.includes("speaking") || disableSelection) && (
|
{(selectedModules.includes("speaking") || disableSelection) && (
|
||||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||||
)}
|
)}
|
||||||
{selectedModules.includes("level") && (
|
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
||||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{!disableSelection && (
|
{!disableSelection && (
|
||||||
<div
|
<div
|
||||||
onClick={
|
onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
|
||||||
selectedModules.length === 0 ||
|
|
||||||
selectedModules.includes("level")
|
|
||||||
? () => toggleModule("level")
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
|
||||||
selectedModules.includes("level") || disableSelection
|
selectedModules.includes("level") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
||||||
? "border-mti-purple-light"
|
)}>
|
||||||
: "border-mti-gray-platinum",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="bg-ielts-level absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
<div className="bg-ielts-level absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||||
<BsClipboard className="h-7 w-7 text-white" />
|
<BsClipboard className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold">Level:</span>
|
<span className="font-semibold">Level:</span>
|
||||||
<p className="text-left text-xs">
|
<p className="text-left text-xs">You'll be able to test your english level with multiple choice questions.</p>
|
||||||
You'll be able to test your english level with multiple
|
{!selectedModules.includes("level") && selectedModules.length === 0 && !disableSelection && (
|
||||||
choice questions.
|
|
||||||
</p>
|
|
||||||
{!selectedModules.includes("level") &&
|
|
||||||
selectedModules.length === 0 &&
|
|
||||||
!disableSelection && (
|
|
||||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||||
)}
|
)}
|
||||||
{(selectedModules.includes("level") || disableSelection) && (
|
{(selectedModules.includes("level") || disableSelection) && (
|
||||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||||
)}
|
)}
|
||||||
{!selectedModules.includes("level") &&
|
{!selectedModules.includes("level") && selectedModules.length > 0 && (
|
||||||
selectedModules.length > 0 && (
|
|
||||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -371,68 +257,51 @@ export default function Selection({
|
|||||||
<div className="flex w-full flex-col items-center gap-3">
|
<div className="flex w-full flex-col items-center gap-3">
|
||||||
<div
|
<div
|
||||||
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
|
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
|
||||||
onClick={() => setAvoidRepeatedExams((prev) => !prev)}
|
onClick={() => setAvoidRepeatedExams((prev) => !prev)}>
|
||||||
>
|
|
||||||
<input type="checkbox" className="hidden" />
|
<input type="checkbox" className="hidden" />
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
avoidRepeatedExams && "!bg-mti-purple-light ",
|
avoidRepeatedExams && "!bg-mti-purple-light ",
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
<BsCheck color="white" className="h-full w-full" />
|
<BsCheck color="white" className="h-full w-full" />
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span className="tooltip" data-tip="If possible, the platform will choose exams not yet done.">
|
||||||
className="tooltip"
|
|
||||||
data-tip="If possible, the platform will choose exams not yet done."
|
|
||||||
>
|
|
||||||
Avoid Repeated Questions
|
Avoid Repeated Questions
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
|
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
|
||||||
// onClick={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
|
onClick={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
|
||||||
>
|
<input type="checkbox" className="hidden" />
|
||||||
<input type="checkbox" className="hidden" disabled />
|
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out",
|
||||||
variant === "full" && "!bg-mti-purple-light ",
|
variant === "full" && "!bg-mti-purple-light ",
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
<BsCheck color="white" className="h-full w-full" />
|
<BsCheck color="white" className="h-full w-full" />
|
||||||
</div>
|
</div>
|
||||||
<span>Full length exams</span>
|
<span>Full length exams</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="tooltip w-full" data-tip={`Your screen size is too small to do ${page}`}>
|
||||||
className="tooltip w-full"
|
<Button color="purple" className="w-full max-w-xs px-12 md:hidden" disabled>
|
||||||
data-tip={`Your screen size is too small to do ${page}`}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
className="w-full max-w-xs px-12 md:hidden"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
Start Exam
|
Start Exam
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onStart(
|
onStart(
|
||||||
!disableSelection
|
!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"],
|
||||||
? selectedModules.sort(sortByModuleName)
|
|
||||||
: ["reading", "listening", "writing", "speaking"],
|
|
||||||
avoidRepeatedExams,
|
avoidRepeatedExams,
|
||||||
variant,
|
variant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
color="purple"
|
color="purple"
|
||||||
className="-md:hidden w-full max-w-xs px-12 md:self-end"
|
className="-md:hidden w-full max-w-xs px-12 md:self-end"
|
||||||
disabled={selectedModules.length === 0 && !disableSelection}
|
disabled={selectedModules.length === 0 && !disableSelection}>
|
||||||
>
|
|
||||||
Start Exam
|
Start Exam
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
235
src/exams/pdf/level.test.report.tsx
Normal file
235
src/exams/pdf/level.test.report.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/* eslint-disable jsx-a11y/alt-text */
|
||||||
|
import React from "react";
|
||||||
|
import { Document, Page, View, Text, Image } from "@react-pdf/renderer";
|
||||||
|
import { ModuleScore } from "@/interfaces/module.scores";
|
||||||
|
import { styles } from "./styles";
|
||||||
|
import TestReportFooter from "./test.report.footer";
|
||||||
|
|
||||||
|
import { StyleSheet } from "@react-pdf/renderer";
|
||||||
|
|
||||||
|
const customStyles = StyleSheet.create({
|
||||||
|
testDetails: {
|
||||||
|
display: "flex",
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
testDetailsContainer: {
|
||||||
|
display: "flex",
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
tableRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
},
|
||||||
|
tableCol70: {
|
||||||
|
width: "70%", // First column width (50%)
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#000",
|
||||||
|
// padding: 5,
|
||||||
|
},
|
||||||
|
tableCol25: {
|
||||||
|
width: "16.67%", // Remaining four columns each get 1/6 of the total width (50% / 3 = 16.67%)
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#000",
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
tableCol20: {
|
||||||
|
width: "20%", // Width for each of the 5 sub-columns (50% / 5 = 20%)
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#000",
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
tableCol10: {
|
||||||
|
width: "10%", // Width for each of the 5 sub-columns (50% / 5 = 20%)
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#000",
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
tableCellHeader: {
|
||||||
|
fontSize: 12,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
tableCellHeaderColor: {
|
||||||
|
backgroundColor: "#d3d3d3",
|
||||||
|
},
|
||||||
|
tableCell: {
|
||||||
|
fontSize: 10,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
date: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
id: string;
|
||||||
|
gender?: string;
|
||||||
|
passportId: string;
|
||||||
|
corporateName: string;
|
||||||
|
downloadDate: string;
|
||||||
|
userId: string;
|
||||||
|
uniqueExercises: { name: string; result: string }[];
|
||||||
|
timeSpent: string;
|
||||||
|
score: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LevelTestReport = ({
|
||||||
|
date,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
id,
|
||||||
|
gender,
|
||||||
|
passportId,
|
||||||
|
corporateName,
|
||||||
|
downloadDate,
|
||||||
|
userId,
|
||||||
|
uniqueExercises,
|
||||||
|
timeSpent,
|
||||||
|
score,
|
||||||
|
}: Props) => {
|
||||||
|
const defaultTextStyle = [styles.textFont, { fontSize: 8 }];
|
||||||
|
return (
|
||||||
|
<Document>
|
||||||
|
<Page style={styles.body} orientation="landscape">
|
||||||
|
<Text style={[styles.textFont, styles.textBold, { fontSize: 11 }]}>
|
||||||
|
Corporate Name: {corporateName}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.textMargin}>
|
||||||
|
<Text style={defaultTextStyle}>
|
||||||
|
Report Download date: {downloadDate}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.textFont, styles.textBold, { fontSize: 11 }]}>
|
||||||
|
Test Information: {id}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.textMargin}>
|
||||||
|
<Text style={defaultTextStyle}>Date of Test: {date}</Text>
|
||||||
|
<Text style={defaultTextStyle}>Candidates name: {name}</Text>
|
||||||
|
<Text style={defaultTextStyle}>Email: {email}</Text>
|
||||||
|
<Text style={defaultTextStyle}>National ID: {passportId}</Text>
|
||||||
|
|
||||||
|
<Text style={defaultTextStyle}>Gender: {gender}</Text>
|
||||||
|
<Text style={defaultTextStyle}>Candidate ID: {userId}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={customStyles.table}>
|
||||||
|
{/* Header Row */}
|
||||||
|
<View style={customStyles.tableRow}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
customStyles.tableCol70,
|
||||||
|
customStyles.tableCellHeaderColor,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[customStyles.tableCellHeader, { padding: 5 }]}>
|
||||||
|
Test sections
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
customStyles.tableCol20,
|
||||||
|
customStyles.tableCellHeaderColor,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={customStyles.tableCellHeader}>Time spent</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
customStyles.tableCol10,
|
||||||
|
customStyles.tableCellHeaderColor,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={customStyles.tableCellHeader}>Score</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={customStyles.tableRow}>
|
||||||
|
<View style={customStyles.tableCol70}>
|
||||||
|
<View style={customStyles.tableRow}>
|
||||||
|
{uniqueExercises.map((exercise, index) => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
customStyles.tableCol20,
|
||||||
|
index !== uniqueExercises.length - 1
|
||||||
|
? { borderWidth: 0, borderRightWidth: 1 }
|
||||||
|
: { borderWidth: 0 },
|
||||||
|
]}
|
||||||
|
key={index}
|
||||||
|
>
|
||||||
|
<Text style={customStyles.tableCell}>Part {index + 1}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={customStyles.tableCol20}>
|
||||||
|
<Text style={customStyles.tableCell}></Text>
|
||||||
|
</View>
|
||||||
|
<View style={customStyles.tableCol10}>
|
||||||
|
<Text style={customStyles.tableCell}></Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={customStyles.tableRow}>
|
||||||
|
<View style={customStyles.tableCol70}>
|
||||||
|
<View style={customStyles.tableRow}>
|
||||||
|
{uniqueExercises.map((exercise, index) => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
customStyles.tableCol20,
|
||||||
|
index !== uniqueExercises.length - 1
|
||||||
|
? { borderWidth: 0, borderRightWidth: 1 }
|
||||||
|
: { borderWidth: 0 },
|
||||||
|
]}
|
||||||
|
key={index}
|
||||||
|
>
|
||||||
|
<Text style={customStyles.tableCell}>{exercise.name}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={customStyles.tableCol20}>
|
||||||
|
<Text style={customStyles.tableCell}></Text>
|
||||||
|
</View>
|
||||||
|
<View style={customStyles.tableCol10}>
|
||||||
|
<Text style={customStyles.tableCell}></Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={customStyles.tableRow}>
|
||||||
|
<View style={customStyles.tableCol70}>
|
||||||
|
<View style={customStyles.tableRow}>
|
||||||
|
{uniqueExercises.map((exercise, index) => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
customStyles.tableCol20,
|
||||||
|
index !== uniqueExercises.length - 1
|
||||||
|
? { borderWidth: 0, borderRightWidth: 1 }
|
||||||
|
: { borderWidth: 0 },
|
||||||
|
]}
|
||||||
|
key={index}
|
||||||
|
>
|
||||||
|
<Text style={customStyles.tableCell}>
|
||||||
|
{exercise.result}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={customStyles.tableCol20}>
|
||||||
|
<Text style={customStyles.tableCell}>{timeSpent}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={customStyles.tableCol10}>
|
||||||
|
<Text style={customStyles.tableCell}>{score}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TestReportFooter userId={userId} />
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LevelTestReport;
|
||||||
@@ -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!,
|
||||||
|
}
|
||||||
|
|||||||
44
src/hooks/useAssignmentCorporates.tsx
Normal file
44
src/hooks/useAssignmentCorporates.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { AssignmentWithCorporateId } from "@/interfaces/results";
|
||||||
|
import axios from "axios";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function useAssignmentsCorporates({
|
||||||
|
corporates,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
}: {
|
||||||
|
corporates: string[];
|
||||||
|
startDate: Date | null;
|
||||||
|
endDate: Date | null;
|
||||||
|
}) {
|
||||||
|
const [assignments, setAssignments] = useState<AssignmentWithCorporateId[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
|
const getData = () => {
|
||||||
|
if (corporates.length === 0) {
|
||||||
|
setAssignments([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
const urlSearchParams = new URLSearchParams({
|
||||||
|
ids: corporates.join(","),
|
||||||
|
...(startDate ? { startDate: startDate.toISOString() } : {}),
|
||||||
|
...(endDate ? { endDate: endDate.toISOString() } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
axios
|
||||||
|
.get<AssignmentWithCorporateId[]>(
|
||||||
|
`/api/assignments/corporate?${urlSearchParams.toString()}`
|
||||||
|
)
|
||||||
|
.then(async (response) => {
|
||||||
|
setAssignments(response.data);
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(getData, [corporates, startDate, endDate]);
|
||||||
|
|
||||||
|
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,8 +1,12 @@
|
|||||||
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";
|
||||||
|
|
||||||
export default function useAssignments({assigner, assignees}: {assigner?: string; assignees?: string}) {
|
const instance = Axios.create();
|
||||||
|
const axios = setupCache(instance);
|
||||||
|
|
||||||
|
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);
|
||||||
const [isError, setIsError] = useState(false);
|
const [isError, setIsError] = useState(false);
|
||||||
@@ -10,12 +14,13 @@ export default function useAssignments({assigner, assignees}: {assigner?: string
|
|||||||
const getData = () => {
|
const getData = () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.get<Assignment[]>("/api/assignments")
|
.get<Assignment[]>(!corporate ? "/api/assignments" : `/api/assignments/corporate/${corporate}`)
|
||||||
.then((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;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (assignees) {
|
if (assignees) {
|
||||||
setAssignments(response.data.filter((a) => a.assignees.filter((x) => assignees.includes(x)).length > 0));
|
setAssignments(response.data.filter((a) => a.assignees.filter((x) => assignees.includes(x)).length > 0));
|
||||||
return;
|
return;
|
||||||
@@ -26,7 +31,7 @@ export default function useAssignments({assigner, assignees}: {assigner?: string
|
|||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(getData, [assignees, assigner]);
|
useEffect(getData, [assignees, assigner, corporate]);
|
||||||
|
|
||||||
return {assignments, isLoading, isError, reload: getData};
|
return {assignments, isLoading, isError, reload: getData};
|
||||||
}
|
}
|
||||||
|
|||||||
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,33 +1,45 @@
|
|||||||
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";
|
||||||
|
|
||||||
export default function useGroups(admin?: string, userType?: string) {
|
const instance = Axios.create();
|
||||||
|
const axios = setupCache(instance);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
admin?: string;
|
||||||
|
userType?: string;
|
||||||
|
adminAdmins?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useGroups({admin, userType, adminAdmins}: Props) {
|
||||||
const [groups, setGroups] = useState<Group[]>([]);
|
const [groups, setGroups] = useState<Group[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isError, setIsError] = useState(false);
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
const isMasterType = userType?.startsWith('master');
|
const isMasterType = userType?.startsWith("master");
|
||||||
|
|
||||||
const getData = () => {
|
const getData = () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const url = admin ? `/api/groups?admin=${admin}` : "/api/groups";
|
const url = admin && !adminAdmins ? `/api/groups?admin=${admin}` : "/api/groups";
|
||||||
axios
|
axios
|
||||||
.get<Group[]>(url)
|
.get<Group[]>(url)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if(isMasterType) {
|
if (isMasterType) return setGroups(response.data);
|
||||||
return setGroups(response.data);
|
|
||||||
}
|
|
||||||
const filter = (g: Group) => g.admin === admin || g.participants.includes(admin || "");
|
|
||||||
|
|
||||||
const filteredGroups = admin ? response.data.filter(filter) : response.data;
|
const filterByAdmins = !!adminAdmins
|
||||||
|
? [adminAdmins, ...response.data.filter((g) => g.participants.includes(adminAdmins)).flatMap((g) => g.admin)]
|
||||||
|
: [admin];
|
||||||
|
const adminFilter = (g: Group) => filterByAdmins.includes(g.admin) || g.participants.includes(admin || "");
|
||||||
|
|
||||||
|
const filteredGroups = !!admin || !!adminAdmins ? response.data.filter(adminFilter) : response.data;
|
||||||
return setGroups(admin ? filteredGroups.map((g) => ({...g, disableEditing: g.disableEditing || g.admin !== admin})) : filteredGroups);
|
return setGroups(admin ? filteredGroups.map((g) => ({...g, disableEditing: g.disableEditing || g.admin !== admin})) : filteredGroups);
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(getData, [admin, isMasterType]);
|
useEffect(getData, [admin, adminAdmins, isMasterType]);
|
||||||
|
|
||||||
return {groups, isLoading, isError, reload: getData};
|
return {groups, isLoading, isError, reload: getData};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,18 +8,12 @@ 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();
|
if (text.length > 0) return search(text, fields, rows);
|
||||||
return rows.filter((row) => {
|
return rows;
|
||||||
return fields.some((fieldsKeys) => {
|
|
||||||
const value = getFieldValue(fieldsKeys, row);
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value.toLowerCase().includes(searchText);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [fields, rows, text]);
|
}, [fields, rows, text]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
text,
|
||||||
rows: updatedRows,
|
rows: updatedRows,
|
||||||
renderSearch,
|
renderSearch,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { BsFilePdf } from "react-icons/bs";
|
import { BsFilePdf, BsFileExcel} from "react-icons/bs";
|
||||||
|
|
||||||
type DownloadingPdf = {
|
type DownloadingPdf = {
|
||||||
[key: string]: boolean;
|
[key: string]: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PdfEndpoint = "stats" | "assignments";
|
type PdfEndpoint = "stats" | "assignments";
|
||||||
|
type FileType = "pdf" | "excel";
|
||||||
|
|
||||||
export const usePDFDownload = (endpoint: PdfEndpoint) => {
|
export const usePDFDownload = (endpoint: PdfEndpoint, file: FileType = 'pdf') => {
|
||||||
const [downloadingPdf, setDownloadingPdf] = React.useState<DownloadingPdf>(
|
const [downloadingPdf, setDownloadingPdf] = React.useState<DownloadingPdf>(
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
@@ -17,7 +18,7 @@ export const usePDFDownload = (endpoint: PdfEndpoint) => {
|
|||||||
const triggerDownload = async (id: string) => {
|
const triggerDownload = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
setDownloadingPdf((prev) => ({ ...prev, [id]: true }));
|
setDownloadingPdf((prev) => ({ ...prev, [id]: true }));
|
||||||
const res = await axios.post(`/api/${endpoint}/${id}/export`);
|
const res = await axios.post(`/api/${endpoint}/${id}/export/${file}`);
|
||||||
toast.success("Report ready!");
|
toast.success("Report ready!");
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = res.data;
|
link.href = res.data;
|
||||||
@@ -45,8 +46,11 @@ export const usePDFDownload = (endpoint: PdfEndpoint) => {
|
|||||||
<span className={`${loadingClasses} loading loading-infinity w-6`} />
|
<span className={`${loadingClasses} loading loading-infinity w-6`} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Icon = file === "excel" ? BsFileExcel : BsFilePdf;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BsFilePdf
|
<Icon
|
||||||
className={`${downloadClasses} text-2xl cursor-pointer`}
|
className={`${downloadClasses} text-2xl cursor-pointer`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
28
src/hooks/usePagination.tsx
Normal file
28
src/hooks/usePagination.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import {useMemo, useState} from "react";
|
||||||
|
|
||||||
|
export default function usePagination<T>(list: T[], size = 25) {
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
|
||||||
|
const items = useMemo(() => list.slice(page * size, (page + 1) * size), [page, size, list]);
|
||||||
|
|
||||||
|
const render = () => (
|
||||||
|
<div className="w-full flex gap-2 justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4 w-fit">
|
||||||
|
<Button className="w-[200px] h-fit" disabled={page === 0} onClick={() => setPage((prev) => prev - 1)}>
|
||||||
|
Previous Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 w-fit">
|
||||||
|
<span className="opacity-80">
|
||||||
|
{page * size + 1} - {(page + 1) * size > list.length ? list.length : (page + 1) * size} / {list.length}
|
||||||
|
</span>
|
||||||
|
<Button className="w-[200px]" disabled={(page + 1) * size >= list.length} onClick={() => setPage((prev) => prev + 1)}>
|
||||||
|
Next Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return {page, items, setPage, render};
|
||||||
|
}
|
||||||
32
src/hooks/usePermissions.tsx
Normal file
32
src/hooks/usePermissions.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import {Exam} from "@/interfaces/exam";
|
||||||
|
import {Permission, PermissionType} from "@/interfaces/permissions";
|
||||||
|
import {ExamState} from "@/stores/examStore";
|
||||||
|
import Axios from "axios";
|
||||||
|
import {setupCache} from "axios-cache-interceptor";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
|
||||||
|
const instance = Axios.create();
|
||||||
|
const axios = setupCache(instance);
|
||||||
|
|
||||||
|
export default function usePermissions(user: string) {
|
||||||
|
const [permissions, setPermissions] = useState<PermissionType[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
|
const getData = () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
axios
|
||||||
|
.get<Permission[]>(`/api/permissions`)
|
||||||
|
.then((response) => {
|
||||||
|
const permissionTypes = response.data
|
||||||
|
.filter((x) => !x.users.includes(user))
|
||||||
|
.reduce((acc, curr) => [...acc, curr.type], [] as PermissionType[]);
|
||||||
|
setPermissions(permissionTypes);
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(getData, [user]);
|
||||||
|
|
||||||
|
return {permissions, isLoading, isError, reload: getData};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
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";
|
||||||
|
|
||||||
export type Session = ExamState & {user: string; id: string; date: string};
|
export type Session = ExamState & {user: string; id: string; date: string};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user