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.
|
||||
# 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
|
||||
|
||||
# If using npm comment out above and use below instead
|
||||
@@ -54,4 +56,4 @@ EXPOSE 3000
|
||||
ENV PORT 3000
|
||||
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} */
|
||||
const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000";
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
reactStrictMode: false,
|
||||
output: "standalone",
|
||||
async headers() {
|
||||
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": {
|
||||
"@beam-australia/react-env": "^3.1.1",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@firebase/util": "^1.9.7",
|
||||
"@headlessui/react": "^1.7.13",
|
||||
"@headlessui/react": "^2.1.2",
|
||||
"@mdi/js": "^7.1.96",
|
||||
"@mdi/react": "^1.6.1",
|
||||
"@next/font": "13.1.6",
|
||||
"@paypal/paypal-js": "^7.1.0",
|
||||
"@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-spring/web": "^9.7.4",
|
||||
"@tanstack/react-table": "^8.10.1",
|
||||
"@types/node": "18.13.0",
|
||||
"@types/react": "18.0.27",
|
||||
"@types/react-dom": "18.0.10",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"axios": "^1.3.5",
|
||||
"axios": "^1",
|
||||
"axios-cache-interceptor": "^1",
|
||||
"bcrypt": "^5.1.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",
|
||||
"country-codes-list": "^1.6.11",
|
||||
"currency-symbol-map": "^5.1.0",
|
||||
"daisyui": "^3.1.5",
|
||||
"eslint": "8.33.0",
|
||||
"eslint-config-next": "13.1.6",
|
||||
"exceljs": "^4.4.0",
|
||||
"express-handlebars": "^7.1.2",
|
||||
"firebase": "9.19.1",
|
||||
"firebase-admin": "^11.10.1",
|
||||
"firebase-scrypt": "^2.2.0",
|
||||
"formidable": "^3.5.0",
|
||||
"formidable-serverless": "^1.1.1",
|
||||
"framer-motion": "^9.0.2",
|
||||
@@ -47,7 +53,8 @@
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
"moment-timezone": "^0.5.44",
|
||||
"next": "13.1.6",
|
||||
"mongodb": "^6.8.1",
|
||||
"next": "^14.2.5",
|
||||
"nodemailer": "^6.9.5",
|
||||
"nodemailer-express-handlebars": "^6.1.0",
|
||||
"primeicons": "^6.0.1",
|
||||
@@ -62,7 +69,7 @@
|
||||
"react-diff-viewer": "^3.1.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-firebase-hooks": "^5.1.1",
|
||||
"react-icons": "^4.8.0",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-lineto": "^3.3.0",
|
||||
"react-media-recorder": "1.6.5",
|
||||
"react-phone-number-input": "^3.3.6",
|
||||
@@ -75,8 +82,10 @@
|
||||
"read-excel-file": "^5.7.1",
|
||||
"short-unique-id": "5.0.2",
|
||||
"stripe": "^13.10.0",
|
||||
"swr": "^2.1.3",
|
||||
"swr": "^2.2.5",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwind-scrollbar-hide": "^1.1.7",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "4.9.5",
|
||||
"use-file-picker": "^2.1.0",
|
||||
"uuid": "^9.0.0",
|
||||
@@ -84,6 +93,7 @@
|
||||
"zustand": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@simbathesailor/use-what-changed": "^2.0.0",
|
||||
"@types/blob-stream": "^0.1.33",
|
||||
"@types/formidable": "^3.4.0",
|
||||
"@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 {
|
||||
user: User;
|
||||
mutateUser: KeyedMutator<User>;
|
||||
mutateUser: (user: User) => void;
|
||||
}
|
||||
|
||||
export default function DemographicInformationInput({user, mutateUser}: Props) {
|
||||
const [country, setCountry] = useState<string>();
|
||||
const [phone, setPhone] = useState<string>();
|
||||
const [country, setCountry] = useState(user.demographicInformation?.country);
|
||||
const [phone, setPhone] = useState(user.demographicInformation?.phone);
|
||||
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
|
||||
const [gender, setGender] = useState<Gender>();
|
||||
const [employment, setEmployment] = useState<EmploymentStatus>();
|
||||
const [position, setPosition] = useState<string>();
|
||||
const [timezone, setTimezone] = useState<string>(moment.tz.guess());
|
||||
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 [commercialRegistration, setCommercialRegistration] = useState<string>();
|
||||
@@ -38,7 +42,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
||||
setIsLoading(true);
|
||||
|
||||
axios
|
||||
.patch("/api/users/update", {
|
||||
.patch<{user: User}>("/api/users/update", {
|
||||
demographicInformation: {
|
||||
country,
|
||||
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,
|
||||
})
|
||||
.then((response) => mutateUser((response.data as {user: User}).user))
|
||||
.then((response) => mutateUser(response.data.user))
|
||||
.catch(() => {
|
||||
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>
|
||||
<CountrySelect value={country} onChange={setCountry} />
|
||||
</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>
|
||||
{user.type === "student" && (
|
||||
<Input
|
||||
@@ -106,7 +118,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
||||
|
||||
<GenderInput value={gender} onChange={setGender} />
|
||||
{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} />}
|
||||
</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,139 +152,8 @@ export default function InteractiveSpeaking({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full gap-9">
|
||||
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="font-semibold">{!!first_title && !!second_title ? `${first_title} & ${second_title}` : title}</span>
|
||||
</div>
|
||||
{prompts && prompts.length > 0 && (
|
||||
<div className="flex flex-col gap-4 w-full items-center">
|
||||
<video key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
|
||||
<source src={prompts[questionIndex].video_url} />
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ReactMediaRecorder
|
||||
audio
|
||||
key={questionIndex}
|
||||
onStop={(blob) => setMediaBlob(blob)}
|
||||
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
|
||||
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||
<p className="text-base font-normal">Record your answer:</p>
|
||||
<div className="flex gap-8 items-center justify-center py-8">
|
||||
{status === "idle" && (
|
||||
<>
|
||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||
{status === "idle" && (
|
||||
<BsMicFill
|
||||
onClick={() => {
|
||||
setRecordingDuration(0);
|
||||
startRecording();
|
||||
setIsRecording(true);
|
||||
}}
|
||||
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{status === "recording" && (
|
||||
<>
|
||||
<div className="flex gap-4 items-center">
|
||||
<span className="text-xs w-9">
|
||||
{Math.floor(recordingDuration / 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
:
|
||||
{Math.floor(recordingDuration % 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||
<div className="flex gap-4 items-center">
|
||||
<BsPauseCircle
|
||||
onClick={() => {
|
||||
setIsRecording(false);
|
||||
pauseRecording();
|
||||
}}
|
||||
className="text-red-500 w-8 h-8 cursor-pointer"
|
||||
/>
|
||||
<BsCheckCircleFill
|
||||
onClick={() => {
|
||||
setIsRecording(false);
|
||||
stopRecording();
|
||||
}}
|
||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{status === "paused" && (
|
||||
<>
|
||||
<div className="flex gap-4 items-center">
|
||||
<span className="text-xs w-9">
|
||||
{Math.floor(recordingDuration / 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
:
|
||||
{Math.floor(recordingDuration % 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||
<div className="flex gap-4 items-center">
|
||||
<BsPlayCircle
|
||||
onClick={() => {
|
||||
setIsRecording(true);
|
||||
resumeRecording();
|
||||
}}
|
||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||
/>
|
||||
<BsCheckCircleFill
|
||||
onClick={() => {
|
||||
setIsRecording(false);
|
||||
stopRecording();
|
||||
}}
|
||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{status === "stopped" && mediaBlobUrl && (
|
||||
<>
|
||||
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||
<div className="flex gap-4 items-center">
|
||||
<BsTrashFill
|
||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||
onClick={() => {
|
||||
setRecordingDuration(0);
|
||||
clearBlobUrl();
|
||||
setMediaBlob(undefined);
|
||||
}}
|
||||
/>
|
||||
|
||||
<BsMicFill
|
||||
onClick={() => {
|
||||
clearBlobUrl();
|
||||
setRecordingDuration(0);
|
||||
startRecording();
|
||||
setIsRecording(true);
|
||||
setMediaBlob(undefined);
|
||||
}}
|
||||
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="self-end flex justify-between w-full gap-8">
|
||||
<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>
|
||||
@@ -292,6 +161,148 @@ export default function InteractiveSpeaking({
|
||||
{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 w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="font-semibold">{!!first_title && !!second_title ? `${first_title} & ${second_title}` : title}</span>
|
||||
</div>
|
||||
{prompts && prompts.length > 0 && (
|
||||
<div className="flex flex-col gap-4 w-full items-center">
|
||||
<video key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
|
||||
<source src={prompts[questionIndex].video_url} />
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ReactMediaRecorder
|
||||
audio
|
||||
key={questionIndex}
|
||||
onStop={(blob) => setMediaBlob(blob)}
|
||||
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
|
||||
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||
<p className="text-base font-normal">Record your answer:</p>
|
||||
<div className="flex gap-8 items-center justify-center py-8">
|
||||
{status === "idle" && (
|
||||
<>
|
||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||
{status === "idle" && (
|
||||
<BsMicFill
|
||||
onClick={() => {
|
||||
setRecordingDuration(0);
|
||||
startRecording();
|
||||
setIsRecording(true);
|
||||
}}
|
||||
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{status === "recording" && (
|
||||
<>
|
||||
<div className="flex gap-4 items-center">
|
||||
<span className="text-xs w-9">
|
||||
{Math.floor(recordingDuration / 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
:
|
||||
{Math.floor(recordingDuration % 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||
<div className="flex gap-4 items-center">
|
||||
<BsPauseCircle
|
||||
onClick={() => {
|
||||
setIsRecording(false);
|
||||
pauseRecording();
|
||||
}}
|
||||
className="text-red-500 w-8 h-8 cursor-pointer"
|
||||
/>
|
||||
<BsCheckCircleFill
|
||||
onClick={() => {
|
||||
setIsRecording(false);
|
||||
stopRecording();
|
||||
}}
|
||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{status === "paused" && (
|
||||
<>
|
||||
<div className="flex gap-4 items-center">
|
||||
<span className="text-xs w-9">
|
||||
{Math.floor(recordingDuration / 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
:
|
||||
{Math.floor(recordingDuration % 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||
<div className="flex gap-4 items-center">
|
||||
<BsPlayCircle
|
||||
onClick={() => {
|
||||
setIsRecording(true);
|
||||
resumeRecording();
|
||||
}}
|
||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||
/>
|
||||
<BsCheckCircleFill
|
||||
onClick={() => {
|
||||
setIsRecording(false);
|
||||
stopRecording();
|
||||
}}
|
||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{status === "stopped" && mediaBlobUrl && (
|
||||
<>
|
||||
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||
<div className="flex gap-4 items-center">
|
||||
<BsTrashFill
|
||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||
onClick={() => {
|
||||
setRecordingDuration(0);
|
||||
clearBlobUrl();
|
||||
setMediaBlob(undefined);
|
||||
}}
|
||||
/>
|
||||
|
||||
<BsMicFill
|
||||
onClick={() => {
|
||||
clearBlobUrl();
|
||||
setRecordingDuration(0);
|
||||
startRecording();
|
||||
setIsRecording(true);
|
||||
setMediaBlob(undefined);
|
||||
}}
|
||||
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="self-end flex justify-between w-full gap-8">
|
||||
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||
Back
|
||||
</Button>
|
||||
<Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
|
||||
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,13 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
||||
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [answers, setAnswers]);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
if (event.over && event.over.id.toString().startsWith("droppable")) {
|
||||
const optionID = event.active.id.toString().replace("draggable_option_", "");
|
||||
@@ -93,7 +100,24 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
||||
}, [hasExamEnded]);
|
||||
|
||||
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">
|
||||
<span className="text-sm w-full leading-6">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
@@ -143,6 +167,6 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
||||
import {MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap} from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import clsx from "clsx";
|
||||
import {useEffect, useState} from "react";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import {CommonProps} from ".";
|
||||
import Button from "../Low/Button";
|
||||
import {v4} from "uuid";
|
||||
|
||||
function Question({
|
||||
id,
|
||||
@@ -14,22 +15,26 @@ function Question({
|
||||
options,
|
||||
userSolution,
|
||||
onSelectOption,
|
||||
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
||||
}: MultipleChoiceQuestion & {
|
||||
userSolution: string | undefined;
|
||||
onSelectOption?: (option: string) => void;
|
||||
showSolution?: boolean;
|
||||
}) {
|
||||
const renderPrompt = (prompt: string) => {
|
||||
return reactStringReplace(prompt, /((<u>)[\w\s']+(<\/u>))/g, (match) => {
|
||||
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
|
||||
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 (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="flex flex-col gap-8">
|
||||
{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>
|
||||
</>
|
||||
</span>
|
||||
)}
|
||||
@@ -37,24 +42,26 @@ function Question({
|
||||
{variant === "image" &&
|
||||
options.map((option) => (
|
||||
<div
|
||||
key={option.id.toString()}
|
||||
key={v4()}
|
||||
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
||||
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",
|
||||
)}>
|
||||
<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()}`} />
|
||||
</div>
|
||||
))}
|
||||
{variant === "text" &&
|
||||
options.map((option) => (
|
||||
<div
|
||||
key={option.id.toString()}
|
||||
key={v4()}
|
||||
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
||||
className={clsx(
|
||||
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm",
|
||||
userSolution === option.id.toString() && "border-mti-purple-light",
|
||||
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none",
|
||||
userSolution === option.id.toString() && "!bg-mti-purple-light !text-white",
|
||||
)}>
|
||||
<span className="font-semibold">{option.id.toString()}.</span>
|
||||
<span>{option.text}</span>
|
||||
@@ -68,79 +75,147 @@ function Question({
|
||||
export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
||||
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||
|
||||
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
const {questionIndex, exerciseIndex, exam, shuffles, hasExamEnded, partIndex, setQuestionIndex, setCurrentSolution} = useExamStore(
|
||||
(state) => state,
|
||||
);
|
||||
|
||||
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
|
||||
|
||||
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(() => {
|
||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasExamEnded]);
|
||||
|
||||
const onSelectOption = (option: string) => {
|
||||
const question = questions[questionIndex];
|
||||
const onSelectOption = (option: string, question: MultipleChoiceQuestion) => {
|
||||
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 total = questions.length;
|
||||
const correct = answers.filter(
|
||||
(x) => questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
|
||||
).length;
|
||||
const missing = total - answers.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length;
|
||||
const correct = answers.filter((x) => {
|
||||
const matchingQuestion = questions.find((y) => {
|
||||
return y.id.toString() === x.question.toString();
|
||||
});
|
||||
|
||||
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};
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
if (questionIndex === questions.length - 1) {
|
||||
onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
if (questionIndex + 1 >= questions.length - 1) {
|
||||
onNext({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
|
||||
} else {
|
||||
setQuestionIndex(questionIndex + 1);
|
||||
setQuestionIndex(questionIndex + 2);
|
||||
}
|
||||
|
||||
scrollToTop();
|
||||
};
|
||||
|
||||
const back = () => {
|
||||
if (questionIndex === 0) {
|
||||
onBack({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
onBack({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
|
||||
} else {
|
||||
setQuestionIndex(questionIndex - 1);
|
||||
if (exam?.module === "level" && typeof exam.parts[0].intro !== "undefined" && questionIndex === 0) return;
|
||||
setQuestionIndex(questionIndex - 2);
|
||||
}
|
||||
|
||||
scrollToTop();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
<span className="text-xl font-semibold">{prompt}</span>
|
||||
{questionIndex < questions.length && (
|
||||
<Question
|
||||
{...questions[questionIndex]}
|
||||
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
|
||||
onSelectOption={onSelectOption}
|
||||
/>
|
||||
)}
|
||||
</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">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-between w-full gap-8">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={back}
|
||||
className="max-w-[200px] w-full"
|
||||
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
||||
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>
|
||||
</div>
|
||||
</>
|
||||
|
||||
<div className="flex flex-col gap-4 mt-4 mb-20">
|
||||
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||
{/*<span className="text-xl font-semibold mb-2">{"Select the appropriate option."}</span>*/}
|
||||
{questionIndex < questions.length && (
|
||||
<Question
|
||||
{...questions[questionIndex]}
|
||||
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
|
||||
onSelectOption={(option) => onSelectOption(option, questions[questionIndex])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{questionIndex + 1 < questions.length && (
|
||||
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||
<Question
|
||||
{...questions[questionIndex + 1]}
|
||||
userSolution={answers.find((x) => questions[questionIndex + 1].id === x.question)?.option}
|
||||
onSelectOption={(option) => onSelectOption(option, questions[questionIndex + 1])}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={back}
|
||||
className="max-w-[200px] w-full"
|
||||
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
||||
{exam &&
|
||||
exam.module === "level" &&
|
||||
partIndex === exam.parts.length - 1 &&
|
||||
exerciseIndex === exam.parts[partIndex].exercises.length - 1 &&
|
||||
questionIndex + 1 >= questions.length - 1
|
||||
? "Submit"
|
||||
: "Next"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { SpeakingExercise } from "@/interfaces/exam";
|
||||
import { CommonProps } from ".";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs";
|
||||
import {SpeakingExercise} from "@/interfaces/exam";
|
||||
import {CommonProps} from ".";
|
||||
import {Fragment, useEffect, useState} from "react";
|
||||
import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs";
|
||||
import dynamic from "next/dynamic";
|
||||
import Button from "../Low/Button";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import { downloadBlob } from "@/utils/evaluation";
|
||||
import {downloadBlob} from "@/utils/evaluation";
|
||||
import axios from "axios";
|
||||
import Modal from "../Modal";
|
||||
|
||||
const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
|
||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function Speaking({ id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack }: SpeakingExercise & CommonProps) {
|
||||
export default function Speaking({id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||
@@ -28,7 +28,7 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
||||
const saveToStorage = async () => {
|
||||
if (mediaBlob && mediaBlob.startsWith("blob")) {
|
||||
const blobBuffer = await downloadBlob(mediaBlob);
|
||||
const audioFile = new File([blobBuffer], "audio.wav", { type: "audio/wav" });
|
||||
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
|
||||
|
||||
const seed = Math.random().toString().replace("0.", "");
|
||||
|
||||
@@ -42,8 +42,8 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
||||
},
|
||||
};
|
||||
|
||||
const response = await axios.post<{ path: string }>("/api/storage/insert", formData, config);
|
||||
if (audioURL) await axios.post("/api/storage/delete", { path: audioURL });
|
||||
const response = await axios.post<{path: string}>("/api/storage/insert", formData, config);
|
||||
if (audioURL) await axios.post("/api/storage/delete", {path: audioURL});
|
||||
return response.data.path;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
||||
|
||||
useEffect(() => {
|
||||
if (userSolutions.length > 0) {
|
||||
const { solution } = userSolutions[0] as { solution?: string };
|
||||
const {solution} = userSolutions[0] as {solution?: string};
|
||||
if (solution && !mediaBlob) setMediaBlob(solution);
|
||||
if (solution && !solution.startsWith("blob")) setAudioURL(solution);
|
||||
}
|
||||
@@ -79,8 +79,8 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
||||
const next = async () => {
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
|
||||
score: { correct: 0, total: 100, missing: 0 },
|
||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||
score: {correct: 0, total: 100, missing: 0},
|
||||
type,
|
||||
});
|
||||
};
|
||||
@@ -88,8 +88,8 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
||||
const back = async () => {
|
||||
onBack({
|
||||
exercise: id,
|
||||
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
|
||||
score: { correct: 0, total: 100, missing: 0 },
|
||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||
score: {correct: 0, total: 100, missing: 0},
|
||||
type,
|
||||
});
|
||||
};
|
||||
@@ -98,7 +98,7 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
||||
const newText = e.target.value;
|
||||
const words = newText.match(/\S+/g);
|
||||
const wordCount = words ? words.length : 0;
|
||||
|
||||
|
||||
if (wordCount <= 100) {
|
||||
setInputText(newText);
|
||||
} else {
|
||||
@@ -110,188 +110,14 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
||||
if (count > 100) break;
|
||||
lastIndex = match.index! + match[0].length;
|
||||
}
|
||||
|
||||
|
||||
setInputText(newText.slice(0, lastIndex));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full gap-9">
|
||||
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
|
||||
<div className="flex flex-col items-center justify-center gap-4 w-full h-full">
|
||||
<div className="flex flex-col gap-1 ml-4">
|
||||
{prompts.map((x, index) => (
|
||||
<li className="italic" key={index}>
|
||||
{x}
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
{!!suffix && <span className="font-bold">{suffix}</span>}
|
||||
</div>
|
||||
</Modal>
|
||||
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-0">
|
||||
<span className="font-semibold">{title}</span>
|
||||
{prompts.length > 0 && (
|
||||
<span className="font-semibold">You should talk for at least 1 minute and 30 seconds for your answer to be valid.</span>
|
||||
)}
|
||||
</div>
|
||||
{!video_url && (
|
||||
<span className="font-regular">
|
||||
{text.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
<span>{line}</span>
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 items-center">
|
||||
{video_url && (
|
||||
<div className="flex flex-col gap-4 w-full items-center">
|
||||
<video key={id} autoPlay controls className="max-w-3xl rounded-xl">
|
||||
<source src={video_url} />
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
{prompts && prompts.length > 0 && <Button onClick={() => setIsPromptsModalOpen(true)}>View Prompts</Button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{prompts && prompts.length > 0 && (
|
||||
<div className="w-full h-full flex flex-col gap-4">
|
||||
<textarea
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className="w-full h-full min-h-[200px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
||||
onChange={handleNoteWriting}
|
||||
value={inputText}
|
||||
placeholder="Write your notes here..."
|
||||
spellCheck={false}
|
||||
/>
|
||||
<span className="text-base self-end text-mti-gray-cool">Word Count: {(inputText.match(/\S+/g) || []).length}/100</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReactMediaRecorder
|
||||
audio
|
||||
onStop={(blob) => setMediaBlob(blob)}
|
||||
render={({ status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl }) => (
|
||||
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||
<p className="text-base font-normal">Record your answer:</p>
|
||||
<div className="flex gap-8 items-center justify-center py-8">
|
||||
{status === "idle" && !mediaBlob && (
|
||||
<>
|
||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||
{status === "idle" && (
|
||||
<BsMicFill
|
||||
onClick={() => {
|
||||
setRecordingDuration(0);
|
||||
startRecording();
|
||||
setIsRecording(true);
|
||||
}}
|
||||
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{status === "recording" && (
|
||||
<>
|
||||
<div className="flex gap-4 items-center">
|
||||
<span className="text-xs w-9">
|
||||
{Math.floor(recordingDuration / 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
:
|
||||
{Math.floor(recordingDuration % 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||
<div className="flex gap-4 items-center">
|
||||
<BsPauseCircle
|
||||
onClick={() => {
|
||||
setIsRecording(false);
|
||||
pauseRecording();
|
||||
}}
|
||||
className="text-red-500 w-8 h-8 cursor-pointer"
|
||||
/>
|
||||
<BsCheckCircleFill
|
||||
onClick={() => {
|
||||
setIsRecording(false);
|
||||
stopRecording();
|
||||
}}
|
||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{status === "paused" && (
|
||||
<>
|
||||
<div className="flex gap-4 items-center">
|
||||
<span className="text-xs w-9">
|
||||
{Math.floor(recordingDuration / 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
:
|
||||
{Math.floor(recordingDuration % 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||
<div className="flex gap-4 items-center">
|
||||
<BsPlayCircle
|
||||
onClick={() => {
|
||||
setIsRecording(true);
|
||||
resumeRecording();
|
||||
}}
|
||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||
/>
|
||||
<BsCheckCircleFill
|
||||
onClick={() => {
|
||||
setIsRecording(false);
|
||||
stopRecording();
|
||||
}}
|
||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
|
||||
<>
|
||||
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||
<div className="flex gap-4 items-center">
|
||||
<BsTrashFill
|
||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||
onClick={() => {
|
||||
setRecordingDuration(0);
|
||||
clearBlobUrl();
|
||||
setMediaBlob(undefined);
|
||||
}}
|
||||
/>
|
||||
|
||||
<BsMicFill
|
||||
onClick={() => {
|
||||
clearBlobUrl();
|
||||
setRecordingDuration(0);
|
||||
startRecording();
|
||||
setIsRecording(true);
|
||||
setMediaBlob(undefined);
|
||||
}}
|
||||
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="self-end flex justify-between w-full gap-8">
|
||||
<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>
|
||||
@@ -299,6 +125,193 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col h-full w-full gap-9">
|
||||
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
|
||||
<div className="flex flex-col items-center justify-center gap-4 w-full h-full">
|
||||
<div className="flex flex-col gap-1 ml-4">
|
||||
{prompts.map((x, index) => (
|
||||
<li className="italic" key={index}>
|
||||
{x}
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
{!!suffix && <span className="font-bold">{suffix}</span>}
|
||||
</div>
|
||||
</Modal>
|
||||
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-0">
|
||||
<span className="font-semibold">{title}</span>
|
||||
{prompts.length > 0 && (
|
||||
<span className="font-semibold">
|
||||
You should talk for at least 1 minute and 30 seconds for your answer to be valid.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!video_url && (
|
||||
<span className="font-regular">
|
||||
{text.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
<span>{line}</span>
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 items-center">
|
||||
{video_url && (
|
||||
<div className="flex flex-col gap-4 w-full items-center">
|
||||
<video key={id} autoPlay controls className="max-w-3xl rounded-xl">
|
||||
<source src={video_url} />
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
{prompts && prompts.length > 0 && <Button onClick={() => setIsPromptsModalOpen(true)}>View Prompts</Button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{prompts && prompts.length > 0 && (
|
||||
<div className="w-full h-full flex flex-col gap-4">
|
||||
<textarea
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className="w-full h-full min-h-[200px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
||||
onChange={handleNoteWriting}
|
||||
value={inputText}
|
||||
placeholder="Write your notes here..."
|
||||
spellCheck={false}
|
||||
/>
|
||||
<span className="text-base self-end text-mti-gray-cool">Word Count: {(inputText.match(/\S+/g) || []).length}/100</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReactMediaRecorder
|
||||
audio
|
||||
onStop={(blob) => setMediaBlob(blob)}
|
||||
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
|
||||
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||
<p className="text-base font-normal">Record your answer:</p>
|
||||
<div className="flex gap-8 items-center justify-center py-8">
|
||||
{status === "idle" && !mediaBlob && (
|
||||
<>
|
||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||
{status === "idle" && (
|
||||
<BsMicFill
|
||||
onClick={() => {
|
||||
setRecordingDuration(0);
|
||||
startRecording();
|
||||
setIsRecording(true);
|
||||
}}
|
||||
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{status === "recording" && (
|
||||
<>
|
||||
<div className="flex gap-4 items-center">
|
||||
<span className="text-xs w-9">
|
||||
{Math.floor(recordingDuration / 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
:
|
||||
{Math.floor(recordingDuration % 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||
<div className="flex gap-4 items-center">
|
||||
<BsPauseCircle
|
||||
onClick={() => {
|
||||
setIsRecording(false);
|
||||
pauseRecording();
|
||||
}}
|
||||
className="text-red-500 w-8 h-8 cursor-pointer"
|
||||
/>
|
||||
<BsCheckCircleFill
|
||||
onClick={() => {
|
||||
setIsRecording(false);
|
||||
stopRecording();
|
||||
}}
|
||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{status === "paused" && (
|
||||
<>
|
||||
<div className="flex gap-4 items-center">
|
||||
<span className="text-xs w-9">
|
||||
{Math.floor(recordingDuration / 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
:
|
||||
{Math.floor(recordingDuration % 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||
<div className="flex gap-4 items-center">
|
||||
<BsPlayCircle
|
||||
onClick={() => {
|
||||
setIsRecording(true);
|
||||
resumeRecording();
|
||||
}}
|
||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||
/>
|
||||
<BsCheckCircleFill
|
||||
onClick={() => {
|
||||
setIsRecording(false);
|
||||
stopRecording();
|
||||
}}
|
||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
|
||||
<>
|
||||
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||
<div className="flex gap-4 items-center">
|
||||
<BsTrashFill
|
||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||
onClick={() => {
|
||||
setRecordingDuration(0);
|
||||
clearBlobUrl();
|
||||
setMediaBlob(undefined);
|
||||
}}
|
||||
/>
|
||||
|
||||
<BsMicFill
|
||||
onClick={() => {
|
||||
clearBlobUrl();
|
||||
setRecordingDuration(0);
|
||||
startRecording();
|
||||
setIsRecording(true);
|
||||
setMediaBlob(undefined);
|
||||
}}
|
||||
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="self-end flex justify-between w-full gap-8">
|
||||
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||
Back
|
||||
</Button>
|
||||
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
|
||||
|
||||
useEffect(() => {
|
||||
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};
|
||||
};
|
||||
|
||||
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 answer = answers.find((x) => x.id === questionId);
|
||||
if (answer && answer.solution === solution) {
|
||||
@@ -39,7 +45,24 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
||||
};
|
||||
|
||||
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">
|
||||
<span className="text-sm w-full leading-6">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
@@ -116,6 +139,6 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ function Blank({
|
||||
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 hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
const {hasExamEnded, setCurrentSolution} = useExamStore((state) => state);
|
||||
|
||||
useEffect(() => {
|
||||
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};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [answers, setAnswers]);
|
||||
|
||||
const renderLines = (line: string) => {
|
||||
return (
|
||||
<span className="text-base leading-5">
|
||||
@@ -87,7 +92,24 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
|
||||
};
|
||||
|
||||
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">
|
||||
<span className="text-sm w-full leading-6">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
@@ -123,6 +145,6 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,7 +84,34 @@ export default function Writing({
|
||||
}, [inputText, wordCounter]);
|
||||
|
||||
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 && (
|
||||
<Transition show={isModalOpen} as={Fragment}>
|
||||
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
||||
@@ -170,6 +197,6 @@ export default function Writing({
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {FillBlanksExercise} from "@/interfaces/exam";
|
||||
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
|
||||
import React from "react";
|
||||
import Input from "@/components/Low/Input";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface Props {
|
||||
exercise: FillBlanksExercise;
|
||||
@@ -8,11 +9,16 @@ interface 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 (
|
||||
<>
|
||||
<Input
|
||||
type="text"
|
||||
type={exercise?.variant && exercise.variant === "mc" ? "textarea" : "text"}
|
||||
label="Prompt"
|
||||
name="prompt"
|
||||
required
|
||||
@@ -24,18 +30,18 @@ const FillBlanksEdit = (props: Props) => {
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
type={exercise?.variant && exercise.variant === "mc" ? "textarea" : "text"}
|
||||
label="Text"
|
||||
name="text"
|
||||
required
|
||||
value={exercise.text}
|
||||
onChange={(value) =>
|
||||
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">
|
||||
{exercise.solutions.map((solution, index) => (
|
||||
<div key={solution.id} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
||||
@@ -47,33 +53,75 @@ const FillBlanksEdit = (props: Props) => {
|
||||
value={solution.solution}
|
||||
onChange={(value) =>
|
||||
updateExercise({
|
||||
solutions: exercise.solutions.map((sol) => (sol.id === solution.id ? {...sol, solution: value} : sol)),
|
||||
solutions: exercise.solutions.map((sol) => (sol.id === solution.id ? { ...sol, solution: value } : sol)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<h1>Words</h1>
|
||||
<div className="w-full flex flex-wrap -mx-2">
|
||||
{exercise.words.map((word, index) => (
|
||||
<div key={index} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
||||
<Input
|
||||
type="text"
|
||||
label={`Word ${index + 1}`}
|
||||
name="word"
|
||||
required
|
||||
value={typeof word === "string" ? word : word.word}
|
||||
onChange={(value) =>
|
||||
updateExercise({
|
||||
words: exercise.words.map((sol, idx) =>
|
||||
index === idx ? (typeof word === "string" ? value : {...word, word: value}) : sol,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<h1 className="mt-4">Words</h1>
|
||||
<div className={clsx(exercise?.variant && exercise.variant === "mc" ? "w-full flex flex-row" : "w-full flex flex-wrap -mx-2")}>
|
||||
{exercise?.variant && exercise.variant === "mc" && typeCheckWordsMC(exercise.words) ?
|
||||
(
|
||||
<div className="flex flex-col w-full">
|
||||
{exercise.words.flatMap((mcOptions, wordIndex) =>
|
||||
<>
|
||||
<label className="font-semibold">{`Word ${wordIndex + 1}`}</label>
|
||||
<div className="flex flex-row">
|
||||
{Object.entries(mcOptions.options).map(([key, value], optionIndex) => (
|
||||
<div key={`${wordIndex}-${optionIndex}-${key}`} className="flex sm:w-1/2 lg:w-1/4 px-2 mb-4">
|
||||
<Input
|
||||
type="text"
|
||||
label={`Option ${key}`}
|
||||
name="word"
|
||||
required
|
||||
value={value}
|
||||
onChange={(newValue) =>
|
||||
updateExercise({
|
||||
words: exercise.words.map((word, idx) =>
|
||||
idx === wordIndex
|
||||
? {
|
||||
...(word as FillBlanksMCOption),
|
||||
options: {
|
||||
...(word as FillBlanksMCOption).options,
|
||||
[key]: newValue
|
||||
}
|
||||
}
|
||||
: word
|
||||
)
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
:
|
||||
(
|
||||
exercise.words.map((word, index) => (
|
||||
<div key={index} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
||||
<Input
|
||||
type="text"
|
||||
label={`Word ${index + 1}`}
|
||||
name="word"
|
||||
required
|
||||
value={typeof word === "string" ? word : ("word" in word ? word.word : "")}
|
||||
onChange={(value) =>
|
||||
updateExercise({
|
||||
words: exercise.words.map((sol, idx) =>
|
||||
index === idx ? (typeof word === "string" ? value : { ...word, word: value }) : sol,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -11,14 +11,15 @@ interface Props {
|
||||
className?: string;
|
||||
navDisabled?: boolean;
|
||||
focusMode?: boolean;
|
||||
bgColor?: string;
|
||||
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();
|
||||
|
||||
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
|
||||
path={router.pathname}
|
||||
user={user}
|
||||
@@ -37,7 +38,8 @@ export default function Layout({user, children, className, navDisabled = false,
|
||||
/>
|
||||
<div
|
||||
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,
|
||||
)}>
|
||||
{children}
|
||||
|
||||
@@ -1,255 +1,179 @@
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {
|
||||
Ticket,
|
||||
TicketStatus,
|
||||
TicketStatusLabel,
|
||||
TicketType,
|
||||
TicketTypeLabel,
|
||||
} from "@/interfaces/ticket";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import {Ticket, TicketStatus, TicketStatusLabel, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
|
||||
import {User} from "@/interfaces/user";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import axios from "axios";
|
||||
import moment from "moment";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import {useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
import Button from "../Low/Button";
|
||||
import Input from "../Low/Input";
|
||||
import Select from "../Low/Select";
|
||||
import { checkAccess } from "@/utils/permissions";
|
||||
import {checkAccess} from "@/utils/permissions";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
ticket: Ticket;
|
||||
onClose: () => void;
|
||||
user: User;
|
||||
ticket: Ticket;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function TicketDisplay({ user, ticket, onClose }: Props) {
|
||||
const [subject] = useState(ticket.subject);
|
||||
const [type, setType] = useState<TicketType>(ticket.type);
|
||||
const [description] = useState(ticket.description);
|
||||
const [reporter] = useState(ticket.reporter);
|
||||
const [reportedFrom] = useState(ticket.reportedFrom);
|
||||
const [status, setStatus] = useState(ticket.status);
|
||||
const [assignedTo, setAssignedTo] = useState<string | null>(
|
||||
ticket.assignedTo || null,
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
export default function TicketDisplay({user, ticket, onClose}: Props) {
|
||||
const [subject] = useState(ticket.subject);
|
||||
const [type, setType] = useState<TicketType>(ticket.type);
|
||||
const [description] = useState(ticket.description);
|
||||
const [reporter] = useState(ticket.reporter);
|
||||
const [reportedFrom] = useState(ticket.reportedFrom);
|
||||
const [status, setStatus] = useState(ticket.status);
|
||||
const [assignedTo, setAssignedTo] = useState<string | null>(ticket.assignedTo || null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { users } = useUsers();
|
||||
const {users} = useUsers();
|
||||
|
||||
const submit = () => {
|
||||
if (!type)
|
||||
return toast.error("Please choose a type!", { toastId: "missing-type" });
|
||||
const submit = () => {
|
||||
if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"});
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.patch(`/api/tickets/${ticket.id}`, {
|
||||
subject,
|
||||
type,
|
||||
description,
|
||||
reporter,
|
||||
reportedFrom,
|
||||
status,
|
||||
assignedTo,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(`The ticket has been updated!`, { toastId: "submitted" });
|
||||
onClose();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong, please try again later!", {
|
||||
toastId: "error",
|
||||
});
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.patch(`/api/tickets/${ticket.id}`, {
|
||||
subject,
|
||||
type,
|
||||
description,
|
||||
reporter,
|
||||
reportedFrom,
|
||||
status,
|
||||
assignedTo,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(`The ticket has been updated!`, {toastId: "submitted"});
|
||||
onClose();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong, please try again later!", {
|
||||
toastId: "error",
|
||||
});
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
const del = () => {
|
||||
if (!confirm("Are you sure you want to delete this ticket?")) return;
|
||||
const del = () => {
|
||||
if (!confirm("Are you sure you want to delete this ticket?")) return;
|
||||
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.delete(`/api/tickets/${ticket.id}`)
|
||||
.then(() => {
|
||||
toast.success(`The ticket has been deleted!`, { toastId: "submitted" });
|
||||
onClose();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong, please try again later!", {
|
||||
toastId: "error",
|
||||
});
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.delete(`/api/tickets/${ticket.id}`)
|
||||
.then(() => {
|
||||
toast.success(`The ticket has been deleted!`, {toastId: "submitted"});
|
||||
onClose();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Something went wrong, please try again later!", {
|
||||
toastId: "error",
|
||||
});
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4 pt-8">
|
||||
<Input
|
||||
label="Subject"
|
||||
type="text"
|
||||
name="subject"
|
||||
placeholder="Subject..."
|
||||
value={subject}
|
||||
onChange={(e) => null}
|
||||
disabled
|
||||
/>
|
||||
return (
|
||||
<form className="flex flex-col gap-4 pt-8">
|
||||
<Input 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="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Status
|
||||
</label>
|
||||
<Select
|
||||
options={Object.keys(TicketStatusLabel).map((x) => ({
|
||||
value: x,
|
||||
label: TicketStatusLabel[x as keyof typeof TicketStatusLabel],
|
||||
}))}
|
||||
value={{ value: status, label: TicketStatusLabel[status] }}
|
||||
onChange={(value) =>
|
||||
setStatus((value?.value as TicketStatus) ?? undefined)
|
||||
}
|
||||
placeholder="Status..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Type
|
||||
</label>
|
||||
<Select
|
||||
options={Object.keys(TicketTypeLabel).map((x) => ({
|
||||
value: x,
|
||||
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
|
||||
}))}
|
||||
value={{ value: type, label: TicketTypeLabel[type] }}
|
||||
onChange={(value) => setType(value!.value as TicketType)}
|
||||
placeholder="Type..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">Status</label>
|
||||
<Select
|
||||
options={Object.keys(TicketStatusLabel).map((x) => ({
|
||||
value: x,
|
||||
label: TicketStatusLabel[x as keyof typeof TicketStatusLabel],
|
||||
}))}
|
||||
value={{value: status, label: TicketStatusLabel[status]}}
|
||||
onChange={(value) => setStatus((value?.value as TicketStatus) ?? undefined)}
|
||||
placeholder="Status..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">Type</label>
|
||||
<Select
|
||||
options={Object.keys(TicketTypeLabel).map((x) => ({
|
||||
value: x,
|
||||
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
|
||||
}))}
|
||||
value={{value: type, label: TicketTypeLabel[type]}}
|
||||
onChange={(value) => setType(value!.value as TicketType)}
|
||||
placeholder="Type..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Assignee
|
||||
</label>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "me", label: "Assign to me" },
|
||||
...users
|
||||
.filter((x) => checkAccess(x, ["admin", "developer", "agent"]))
|
||||
.map((u) => ({
|
||||
value: u.id,
|
||||
label: `${u.name} - ${u.email}`,
|
||||
})),
|
||||
]}
|
||||
disabled={checkAccess(user, ["agent"])}
|
||||
value={
|
||||
assignedTo
|
||||
? {
|
||||
value: assignedTo,
|
||||
label: `${users.find((u) => u.id === assignedTo)?.name} - ${users.find((u) => u.id === assignedTo)?.email}`,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onChange={(value) =>
|
||||
value
|
||||
? setAssignedTo(value.value === "me" ? user.id : value.value)
|
||||
: setAssignedTo(null)
|
||||
}
|
||||
placeholder="Assignee..."
|
||||
isClearable
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<label className="text-mti-gray-dim text-base font-normal">Assignee</label>
|
||||
<Select
|
||||
options={[
|
||||
{value: "me", label: "Assign to me"},
|
||||
...users
|
||||
.filter((x) => checkAccess(x, ["admin", "developer", "agent"]))
|
||||
.map((u) => ({
|
||||
value: u.id,
|
||||
label: `${u.name} - ${u.email}`,
|
||||
})),
|
||||
]}
|
||||
disabled={checkAccess(user, ["agent"])}
|
||||
value={
|
||||
assignedTo
|
||||
? {
|
||||
value: assignedTo,
|
||||
label: `${users.find((u) => u.id === assignedTo)?.name} - ${users.find((u) => u.id === assignedTo)?.email}`,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onChange={(value) => (value ? setAssignedTo(value.value === "me" ? user.id : value.value) : setAssignedTo(null))}
|
||||
placeholder="Assignee..."
|
||||
isClearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||
<Input
|
||||
label="Reported From"
|
||||
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 className="-md:flex-col flex w-full items-center gap-4">
|
||||
<Input label="Reported From" 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 className="-md:flex-col flex w-full items-center gap-4">
|
||||
<Input
|
||||
label="Reporter's Name"
|
||||
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
|
||||
label="Reporter's Type"
|
||||
type="text"
|
||||
name="reporterType"
|
||||
onChange={() => null}
|
||||
value={USER_TYPE_LABELS[reporter.type]}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||
<Input label="Reporter's Name" 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
|
||||
label="Reporter's Type"
|
||||
type="text"
|
||||
name="reporterType"
|
||||
onChange={() => null}
|
||||
value={USER_TYPE_LABELS[reporter.type]}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
|
||||
placeholder="Write your ticket's description here..."
|
||||
contentEditable={false}
|
||||
value={description}
|
||||
spellCheck
|
||||
/>
|
||||
<textarea
|
||||
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
|
||||
placeholder="Write your ticket's description here..."
|
||||
contentEditable={false}
|
||||
value={description}
|
||||
spellCheck
|
||||
/>
|
||||
|
||||
<div className="-md:flex-col-reverse mt-2 flex w-full items-center justify-between gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
className="w-full md:max-w-[200px]"
|
||||
variant="outline"
|
||||
onClick={del}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<div className="-md:flex-col-reverse mt-2 flex w-full items-center justify-between gap-4">
|
||||
<Button type="button" color="red" className="w-full md:max-w-[200px]" variant="outline" onClick={del} isLoading={isLoading}>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
<div className="-md:flex-col-reverse flex w-full items-center justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
className="w-full md:max-w-[200px]"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full md:max-w-[200px]"
|
||||
isLoading={isLoading}
|
||||
onClick={submit}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
<div className="-md:flex-col-reverse flex w-full items-center justify-end gap-4">
|
||||
<Button type="button" color="red" className="w-full md:max-w-[200px]" variant="outline" onClick={onClose} isLoading={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" className="w-full md:max-w-[200px]" isLoading={isLoading} onClick={submit}>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
padding?: string;
|
||||
onClick?: () => void;
|
||||
type?: "button" | "reset" | "submit";
|
||||
}
|
||||
@@ -21,6 +22,7 @@ export default function Button({
|
||||
className,
|
||||
children,
|
||||
type,
|
||||
padding = "py-4 px-6",
|
||||
onClick,
|
||||
}: Props) {
|
||||
const colorClassNames: {[key in typeof color]: {[key in typeof variant]: string}} = {
|
||||
@@ -61,7 +63,8 @@ export default function Button({
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
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],
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -11,14 +11,16 @@ interface Props {
|
||||
|
||||
export default function Checkbox({isChecked, onChange, children, disabled}: Props) {
|
||||
return (
|
||||
<div className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer" onClick={() => {
|
||||
if(disabled) return;
|
||||
onChange(!isChecked);
|
||||
}}>
|
||||
<div
|
||||
className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer"
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
onChange(!isChecked);
|
||||
}}>
|
||||
<input type="checkbox" className="hidden" />
|
||||
<div
|
||||
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",
|
||||
isChecked && "!bg-mti-purple-light ",
|
||||
)}>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import clsx from "clsx";
|
||||
import {useState} from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Props {
|
||||
type: "email" | "text" | "password" | "tel" | "number";
|
||||
type: "email" | "text" | "password" | "tel" | "number" | "textarea";
|
||||
roundness?: "full" | "xl";
|
||||
required?: boolean;
|
||||
label?: string;
|
||||
@@ -11,6 +11,7 @@ interface Props {
|
||||
value?: string | number;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
max?: number;
|
||||
name: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
@@ -23,6 +24,7 @@ export default function Input({
|
||||
required = false,
|
||||
value,
|
||||
defaultValue,
|
||||
max,
|
||||
className,
|
||||
roundness = "full",
|
||||
disabled = false,
|
||||
@@ -30,6 +32,20 @@ export default function Input({
|
||||
}: Props) {
|
||||
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") {
|
||||
return (
|
||||
<div className="relative flex flex-col gap-3 w-full">
|
||||
@@ -72,6 +88,7 @@ export default function Input({
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
max={max}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
min={type === "number" ? 0 : undefined}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -1,51 +1,55 @@
|
||||
import {Module} from "@/interfaces";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {moduleLabels} from "@/utils/moduleUtils";
|
||||
import { Module } from "@/interfaces";
|
||||
import { moduleLabels } from "@/utils/moduleUtils";
|
||||
import clsx from "clsx";
|
||||
import {motion} from "framer-motion";
|
||||
import {ReactNode, useEffect, useState} from "react";
|
||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch } from "react-icons/bs";
|
||||
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 {
|
||||
minTimer: number;
|
||||
module: Module;
|
||||
examLabel?: string;
|
||||
label?: string;
|
||||
exerciseIndex: number;
|
||||
totalExercises: number;
|
||||
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) {
|
||||
const [timer, setTimer] = useState(minTimer * 60);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [warningMode, setWarningMode] = useState(false);
|
||||
export default function ModuleTitle({
|
||||
minTimer,
|
||||
module,
|
||||
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 {timeSpent} = useExamStore((state) => state);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
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" />,
|
||||
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
|
||||
writing: <BsPen className="text-ielts-writing w-6 h-6" />,
|
||||
@@ -53,53 +57,135 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
|
||||
level: <BsClipboard className="text-ielts-level w-6 h-6" />,
|
||||
};
|
||||
|
||||
const isMultipleChoiceLevelExercise = () => {
|
||||
if (exam?.module === 'level' && typeof partIndex === "number" && partIndex > -1) {
|
||||
const currentExercise = (exam as LevelExam).parts[partIndex].exercises[examExerciseIndex];
|
||||
return currentExercise && currentExercise.type === 'multipleChoice';
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const renderMCQuestionGrid = () => {
|
||||
if (!isMultipleChoiceLevelExercise() && !userSolutions) return null;
|
||||
|
||||
const currentExercise = (exam as LevelExam).parts[partIndex!].exercises[examExerciseIndex] as MultipleChoiceExercise;
|
||||
const userSolution = userSolutions!.find((x) => x.exercise.toString() == currentExercise.id.toString())!;
|
||||
const answeredQuestions = new Set(userSolution.solutions.map(sol => sol.question.toString()));
|
||||
const exerciseOffset = Number(currentExercise.questions[0].id);
|
||||
const lastExercise = exerciseOffset + (currentExercise.questions.length - 1);
|
||||
|
||||
const getQuestionColor = (questionId: string, solution: string, userQuestionSolution: string | undefined) => {
|
||||
const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => {
|
||||
if (foundMap) return foundMap;
|
||||
return userSolution.shuffleMaps?.find(map => map.questionID.toString() === questionId.toString()) || null;
|
||||
}, null as ShuffleMap | null);
|
||||
const newSolution = questionShuffleMap ? questionShuffleMap?.map[solution] : solution;
|
||||
|
||||
if (!userSolutions) return "";
|
||||
|
||||
if (!userQuestionSolution) {
|
||||
return "!bg-mti-gray-davy !border--mti-gray-davy !text-mti-gray-davy !text-white hover:!bg-gray-700";
|
||||
}
|
||||
|
||||
return userQuestionSolution === newSolution ?
|
||||
"!bg-mti-purple-light !text-mti-purple-light !text-white hover:!bg-mti-purple-dark" :
|
||||
"!bg-mti-rose-light !border-mti-rose-light !text-mti-rose-light !text-white hover:!bg-mti-rose-dark";
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<h3 className="text-xl font-semibold mb-4 text-center">{`Part ${partIndex + 1} (Questions ${exerciseOffset} - ${lastExercise})`}</h3>
|
||||
<div className="grid grid-cols-5 gap-3 px-4 py-2">
|
||||
{currentExercise.questions.map((_, index) => {
|
||||
const questionNumber = exerciseOffset + index;
|
||||
const isAnswered = answeredQuestions.has(questionNumber.toString());
|
||||
const solution = currentExercise.questions.find((x) => x.id.toString() == questionNumber.toString())!.solution;
|
||||
|
||||
const userQuestionSolution = currentExercise.userSolutions?.find((x) => x.question.toString() == questionNumber.toString())?.option;
|
||||
return (
|
||||
<Button
|
||||
variant={showSolutions ? "solid" : (isAnswered ? "solid" : "outline")}
|
||||
key={index}
|
||||
className={clsx(
|
||||
"w-12 h-12 flex items-center justify-center rounded-lg text-sm font-bold transition-all duration-200 ease-in-out",
|
||||
(showSolutions ?
|
||||
getQuestionColor(questionNumber.toString(), solution, userQuestionSolution) :
|
||||
(isAnswered ?
|
||||
"bg-mti-purple-light border-mti-purple-light text-white hover:bg-mti-purple-dark hover:border-mti-purple-dark" :
|
||||
"bg-white border-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
||||
)
|
||||
)
|
||||
)}
|
||||
onClick={() => { if (typeof runOnClick !== "undefined") { runOnClick(index); } setIsOpen(false); }}
|
||||
>
|
||||
{questionNumber}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-600 text-center">
|
||||
Click a question number to jump to that question
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<TimerEndedModal
|
||||
isOpen={showModal}
|
||||
onClose={() => {
|
||||
setHasExamEnded(true);
|
||||
setShowModal(false);
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
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",
|
||||
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt",
|
||||
{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>
|
||||
)}
|
||||
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-base font-semibold w-12">
|
||||
{timer > 0 && (
|
||||
<div className={clsx("flex gap-6 w-full h-fit items-center", partLabel ? "mt-10" : "mt-5")}>
|
||||
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<div className="w-full flex justify-between">
|
||||
<span className="text-base font-semibold">
|
||||
{module === "level"
|
||||
? (examLabel ? examLabel : "Placement Test")
|
||||
: `${moduleLabels[module]} exam${label ? ` - ${label}` : ''}`
|
||||
}
|
||||
</span>
|
||||
<span className="text-sm font-semibold self-end">
|
||||
Question {exerciseIndex}/{totalExercises}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
|
||||
</div>
|
||||
{isMultipleChoiceLevelExercise() && (
|
||||
<>
|
||||
{Math.floor(timer / 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
:
|
||||
{Math.floor(timer % 60)
|
||||
.toString(10)
|
||||
.padStart(2, "0")}
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{timer <= 0 && <>00:00</>}
|
||||
</span>
|
||||
</motion.div>
|
||||
<div className="flex gap-6 w-full h-fit items-center mt-5">
|
||||
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<div className="w-full flex justify-between">
|
||||
<span className="text-base font-semibold">
|
||||
{moduleLabels[module]} exam {label && `- ${label}`}
|
||||
</span>
|
||||
<span className="text-sm font-semibold self-end">
|
||||
Exercise {exerciseIndex}/{totalExercises}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
206
src/components/Medium/RecordFilter.tsx
Normal file
206
src/components/Medium/RecordFilter.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { User } from "@/interfaces/user";
|
||||
import { checkAccess } from "@/utils/permissions";
|
||||
import Select from "../Low/Select";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import useRecordStore from "@/stores/recordStore";
|
||||
|
||||
|
||||
type TimeFilter = "months" | "weeks" | "days";
|
||||
type Filter = TimeFilter | "assignments" | undefined;
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
filterState: {
|
||||
filter: Filter,
|
||||
setFilter: React.Dispatch<React.SetStateAction<Filter>>
|
||||
},
|
||||
assignments?: boolean;
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const defaultSelectableCorporate = {
|
||||
value: "",
|
||||
label: "All",
|
||||
};
|
||||
|
||||
const RecordFilter: React.FC<Props> = ({
|
||||
user,
|
||||
filterState,
|
||||
assignments = true,
|
||||
children
|
||||
}) => {
|
||||
const { filter, setFilter } = filterState;
|
||||
|
||||
const [statsUserId, setStatsUserId] = useRecordStore((state) => [
|
||||
state.selectedUser,
|
||||
state.setSelectedUser
|
||||
]);
|
||||
|
||||
const { users } = useUsers();
|
||||
const { groups: allGroups } = useGroups({});
|
||||
const { groups } = useGroups({ admin: user?.id, userType: user?.type });
|
||||
|
||||
const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => {
|
||||
setFilter((prev) => (prev === value ? undefined : value));
|
||||
};
|
||||
|
||||
const selectableCorporates = [
|
||||
defaultSelectableCorporate,
|
||||
...users
|
||||
.filter((x) => groups.flatMap((g) => [g.admin, ...g.participants]).includes(x.id))
|
||||
.filter((x) => x.type === "corporate")
|
||||
.map((x) => ({
|
||||
value: x.id,
|
||||
label: `${x.name} - ${x.email}`,
|
||||
})),
|
||||
];
|
||||
|
||||
const [selectedCorporate, setSelectedCorporate] = useState<string>(defaultSelectableCorporate.value);
|
||||
|
||||
const getUsersList = (): User[] => {
|
||||
if (selectedCorporate) {
|
||||
const selectedCorporateGroups = allGroups.filter((x) => x.admin === selectedCorporate);
|
||||
const selectedCorporateGroupsParticipants = selectedCorporateGroups.flatMap((x) => x.participants);
|
||||
|
||||
const userListWithUsers = selectedCorporateGroupsParticipants.map((x) => users.find((y) => y.id === x)) as User[];
|
||||
return userListWithUsers.filter((x) => x);
|
||||
}
|
||||
|
||||
return user.type !== "mastercorporate" ? users : users.filter((x) => groups.flatMap((g) => [g.admin, ...g.participants]).includes(x.id));
|
||||
};
|
||||
|
||||
const corporateFilteredUserList = getUsersList();
|
||||
|
||||
const getSelectedUser = () => {
|
||||
if (selectedCorporate) {
|
||||
const userInCorporate = corporateFilteredUserList.find((x) => x.id === statsUserId);
|
||||
return userInCorporate || corporateFilteredUserList[0];
|
||||
}
|
||||
|
||||
return users.find((x) => x.id === statsUserId) || user;
|
||||
};
|
||||
|
||||
const selectedUser = getSelectedUser();
|
||||
const selectedUserSelectValue = selectedUser
|
||||
? {
|
||||
value: selectedUser.id,
|
||||
label: `${selectedUser.name} - ${selectedUser.email}`,
|
||||
}
|
||||
: {
|
||||
value: "",
|
||||
label: "",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center">
|
||||
<div className="xl:w-3/4">
|
||||
{checkAccess(user, ["developer", "admin", "mastercorporate"]) && !children && (
|
||||
<>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Corporate</label>
|
||||
|
||||
<Select
|
||||
options={selectableCorporates}
|
||||
value={selectableCorporates.find((x) => x.value === selectedCorporate)}
|
||||
onChange={(value) => setSelectedCorporate(value?.value || "")}
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}></Select>
|
||||
<label className="font-normal text-base text-mti-gray-dim">User</label>
|
||||
|
||||
<Select
|
||||
options={corporateFilteredUserList.map((x) => ({
|
||||
value: x.id,
|
||||
label: `${x.name} - ${x.email}`,
|
||||
}))}
|
||||
value={selectedUserSelectValue}
|
||||
onChange={(value) => setStatsUserId(value?.value!)}
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{(user.type === "corporate" || user.type === "teacher") && groups.length > 0 && !children && (
|
||||
<>
|
||||
<label className="font-normal text-base text-mti-gray-dim">User</label>
|
||||
|
||||
<Select
|
||||
options={users
|
||||
.filter((x) => groups.flatMap((y) => y.participants).includes(x.id))
|
||||
.map((x) => ({
|
||||
value: x.id,
|
||||
label: `${x.name} - ${x.email}`,
|
||||
}))}
|
||||
value={selectedUserSelectValue}
|
||||
onChange={(value) => setStatsUserId(value?.value!)}
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex gap-4 w-full justify-center xl:justify-end">
|
||||
{assignments && (
|
||||
<button
|
||||
className={clsx(
|
||||
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||
"transition duration-300 ease-in-out",
|
||||
filter === "assignments" && "!bg-mti-purple-light !text-white",
|
||||
)}
|
||||
onClick={() => toggleFilter("assignments")}>
|
||||
Assignments
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={clsx(
|
||||
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||
"transition duration-300 ease-in-out",
|
||||
filter === "months" && "!bg-mti-purple-light !text-white",
|
||||
)}
|
||||
onClick={() => toggleFilter("months")}>
|
||||
Last month
|
||||
</button>
|
||||
<button
|
||||
className={clsx(
|
||||
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||
"transition duration-300 ease-in-out",
|
||||
filter === "weeks" && "!bg-mti-purple-light !text-white",
|
||||
)}
|
||||
onClick={() => toggleFilter("weeks")}>
|
||||
Last week
|
||||
</button>
|
||||
<button
|
||||
className={clsx(
|
||||
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||
"transition duration-300 ease-in-out",
|
||||
filter === "days" && "!bg-mti-purple-light !text-white",
|
||||
)}
|
||||
onClick={() => toggleFilter("days")}>
|
||||
Last day
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RecordFilter;
|
||||
@@ -40,61 +40,71 @@ export default function SessionCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-mti-gray-anti-flash flex w-64 flex-col gap-3 rounded-xl border p-4 text-black">
|
||||
<span className="flex gap-1">
|
||||
<b>ID:</b>
|
||||
{session.sessionId}
|
||||
</span>
|
||||
<span className="flex gap-1">
|
||||
<b>Date:</b>
|
||||
{moment(session.date).format("DD/MM/YYYY - HH:mm")}
|
||||
</span>
|
||||
<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">
|
||||
{session.selectedModules.sort(sortByModuleName).map((module) => (
|
||||
<div
|
||||
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 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">
|
||||
<b>ID:</b>
|
||||
{session.sessionId}
|
||||
</span>
|
||||
<span className="flex gap-1">
|
||||
<b>Date:</b>
|
||||
{moment(session.date).format("DD/MM/YYYY - HH:mm")}
|
||||
</span>
|
||||
{session.assignment && (
|
||||
<span className="flex flex-col gap-0">
|
||||
<b>Assignment:</b>
|
||||
{session.assignment.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<button
|
||||
onClick={async () => await loadSession(session)}
|
||||
disabled={isLoading}
|
||||
className="bg-mti-green-ultralight w-full hover:bg-mti-green-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
||||
{!isLoading && "Resume"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={deleteSession}
|
||||
disabled={isLoading}
|
||||
className="bg-mti-red-ultralight w-full hover:bg-mti-red-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
||||
{!isLoading && "Delete"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex flex-col gap-3">
|
||||
<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">
|
||||
{session.selectedModules.sort(sortByModuleName).map((module) => (
|
||||
<div
|
||||
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>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<button
|
||||
onClick={async () => await loadSession(session)}
|
||||
disabled={isLoading}
|
||||
className="bg-mti-green-ultralight w-full hover:bg-mti-green-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
||||
{!isLoading && "Resume"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={deleteSession}
|
||||
disabled={isLoading}
|
||||
className="bg-mti-red-ultralight w-full hover:bg-mti-red-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
||||
{!isLoading && "Delete"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
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;
|
||||
title?: string;
|
||||
className?: string;
|
||||
titleClassName?: string;
|
||||
children?: ReactElement;
|
||||
}
|
||||
|
||||
export default function Modal({isOpen, title, className, onClose, children}: Props) {
|
||||
export default function Modal({isOpen, title, className, titleClassName, onClose, children}: Props) {
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-[200]" onClose={onClose}>
|
||||
@@ -41,7 +42,7 @@ export default function Modal({isOpen, title, className, onClose, children}: Pro
|
||||
className,
|
||||
)}>
|
||||
{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}
|
||||
</Dialog.Title>
|
||||
)}
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
import {Step} from "@/interfaces";
|
||||
import {getGradingLabel, getLevelLabel} from "@/utils/score";
|
||||
import clsx from "clsx";
|
||||
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
|
||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
|
||||
const ModuleBadge: React.FC<{ module: string; level?: number }> = ({ module, level }) => (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
||||
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
||||
{/* do not switch to level && it will convert the 0.0 to 0*/}
|
||||
{level !== undefined && (<span className="text-sm">{level.toFixed(1)}</span>)}
|
||||
</div>
|
||||
const ModuleBadge: React.FC<{module: string; level?: number; gradingSystem?: Step[]; className?: string}> = ({
|
||||
module,
|
||||
level,
|
||||
gradingSystem,
|
||||
className,
|
||||
}) => (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex gap-2 justify-center items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
className,
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="w-4 h-4" />}
|
||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
||||
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
||||
{/* do not switch to level && it will convert the 0.0 to 0*/}
|
||||
{level !== undefined && (
|
||||
<span className="text-sm">{module === "level" && gradingSystem ? getGradingLabel(level, gradingSystem) : level.toFixed(1)}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ModuleBadge;
|
||||
export default ModuleBadge;
|
||||
|
||||
@@ -1,219 +1,165 @@
|
||||
import { User } from "@/interfaces/user";
|
||||
import {User} from "@/interfaces/user";
|
||||
import Link from "next/link";
|
||||
import FocusLayer from "@/components/FocusLayer";
|
||||
import { preventNavigation } from "@/utils/navigation.disabled";
|
||||
import { useRouter } from "next/router";
|
||||
import { BsList, BsQuestionCircle, BsQuestionCircleFill } from "react-icons/bs";
|
||||
import {preventNavigation} from "@/utils/navigation.disabled";
|
||||
import {useRouter} from "next/router";
|
||||
import {BsList, BsQuestionCircle, BsQuestionCircleFill} from "react-icons/bs";
|
||||
import clsx from "clsx";
|
||||
import moment from "moment";
|
||||
import MobileMenu from "./MobileMenu";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Type } from "@/interfaces/user";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import {useEffect, useState} from "react";
|
||||
import {Type} from "@/interfaces/user";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import { isUserFromCorporate } from "@/utils/groups";
|
||||
import {isUserFromCorporate} from "@/utils/groups";
|
||||
import Button from "./Low/Button";
|
||||
import Modal from "./Modal";
|
||||
import Input from "./Low/Input";
|
||||
import TicketSubmission from "./High/TicketSubmission";
|
||||
import { Module } from "@/interfaces";
|
||||
import {Module} from "@/interfaces";
|
||||
import Badge from "./Low/Badge";
|
||||
|
||||
import {
|
||||
BsArrowRepeat,
|
||||
BsBook,
|
||||
BsCheck,
|
||||
BsCheckCircle,
|
||||
BsClipboard,
|
||||
BsHeadphones,
|
||||
BsMegaphone,
|
||||
BsPen,
|
||||
BsXCircle,
|
||||
} from "react-icons/bs";
|
||||
import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
||||
interface Props {
|
||||
user: User;
|
||||
navDisabled?: boolean;
|
||||
focusMode?: boolean;
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
path: string;
|
||||
user: User;
|
||||
navDisabled?: boolean;
|
||||
focusMode?: boolean;
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
export default function Navbar({
|
||||
user,
|
||||
path,
|
||||
navDisabled = false,
|
||||
focusMode = false,
|
||||
onFocusLayerMouseEnter,
|
||||
}: Props) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
||||
const [isTicketOpen, setIsTicketOpen] = useState(false);
|
||||
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
||||
const [isTicketOpen, setIsTicketOpen] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const router = useRouter();
|
||||
|
||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||
|
||||
const expirationDateColor = (date: Date) => {
|
||||
const momentDate = moment(date);
|
||||
const today = moment(new Date());
|
||||
const expirationDateColor = (date: Date) => {
|
||||
const momentDate = moment(date);
|
||||
const today = moment(new Date());
|
||||
|
||||
if (today.add(1, "days").isAfter(momentDate))
|
||||
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(7, "days").isAfter(momentDate))
|
||||
return "!bg-mti-orange-ultralight border-mti-orange-light";
|
||||
};
|
||||
if (today.add(1, "days").isAfter(momentDate)) 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(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light";
|
||||
};
|
||||
|
||||
const showExpirationDate = () => {
|
||||
if (!user.subscriptionExpirationDate) return false;
|
||||
const showExpirationDate = () => {
|
||||
if (!user.subscriptionExpirationDate) return false;
|
||||
|
||||
const momentDate = moment(user.subscriptionExpirationDate);
|
||||
const today = moment(new Date());
|
||||
const momentDate = moment(user.subscriptionExpirationDate);
|
||||
const today = moment(new Date());
|
||||
|
||||
return today.add(7, "days").isAfter(momentDate);
|
||||
};
|
||||
return today.add(7, "days").isAfter(momentDate);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user.type !== "student" && user.type !== "teacher")
|
||||
return setDisablePaymentPage(false);
|
||||
isUserFromCorporate(user.id).then((result) =>
|
||||
setDisablePaymentPage(result)
|
||||
);
|
||||
}, [user]);
|
||||
useEffect(() => {
|
||||
if (user.type !== "student" && user.type !== "teacher") return setDisablePaymentPage(false);
|
||||
isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result));
|
||||
}, [user]);
|
||||
|
||||
const badges = [
|
||||
{
|
||||
module: "reading",
|
||||
icon: () => <BsBook className="h-4 w-4 text-white" />,
|
||||
achieved: user.levels.reading >= user.desiredLevels.reading,
|
||||
},
|
||||
const badges = [
|
||||
{
|
||||
module: "reading",
|
||||
icon: () => <BsBook className="h-4 w-4 text-white" />,
|
||||
achieved: user.levels?.reading || 0 >= user.desiredLevels?.reading || 9,
|
||||
},
|
||||
|
||||
{
|
||||
module: "listening",
|
||||
icon: () => <BsHeadphones className="h-4 w-4 text-white" />,
|
||||
achieved: user.levels.listening >= user.desiredLevels.listening,
|
||||
},
|
||||
{
|
||||
module: "writing",
|
||||
icon: () => <BsPen className="h-4 w-4 text-white" />,
|
||||
achieved: user.levels.writing >= user.desiredLevels.writing,
|
||||
},
|
||||
{
|
||||
module: "speaking",
|
||||
icon: () => <BsMegaphone className="h-4 w-4 text-white" />,
|
||||
achieved: user.levels.speaking >= user.desiredLevels.speaking,
|
||||
},
|
||||
{
|
||||
module: "level",
|
||||
icon: () => <BsClipboard className="h-4 w-4 text-white" />,
|
||||
achieved: user.levels.level >= user.desiredLevels.level,
|
||||
},
|
||||
];
|
||||
{
|
||||
module: "listening",
|
||||
icon: () => <BsHeadphones className="h-4 w-4 text-white" />,
|
||||
achieved: user.levels?.listening || 0 >= user.desiredLevels?.listening || 9,
|
||||
},
|
||||
{
|
||||
module: "writing",
|
||||
icon: () => <BsPen className="h-4 w-4 text-white" />,
|
||||
achieved: user.levels?.writing || 0 >= user.desiredLevels?.writing || 9,
|
||||
},
|
||||
{
|
||||
module: "speaking",
|
||||
icon: () => <BsMegaphone className="h-4 w-4 text-white" />,
|
||||
achieved: user.levels?.speaking || 0 >= user.desiredLevels?.speaking || 9,
|
||||
},
|
||||
{
|
||||
module: "level",
|
||||
icon: () => <BsClipboard className="h-4 w-4 text-white" />,
|
||||
achieved: user.levels?.level || 0 >= user.desiredLevels?.level || 9,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isTicketOpen}
|
||||
onClose={() => setIsTicketOpen(false)}
|
||||
title="Submit a ticket"
|
||||
>
|
||||
<TicketSubmission
|
||||
user={user}
|
||||
page={router.asPath}
|
||||
onClose={() => setIsTicketOpen(false)}
|
||||
/>
|
||||
</Modal>
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={isTicketOpen} onClose={() => setIsTicketOpen(false)} title="Submit a ticket">
|
||||
<TicketSubmission user={user} page={router.asPath} onClose={() => setIsTicketOpen(false)} />
|
||||
</Modal>
|
||||
|
||||
{user && (
|
||||
<MobileMenu
|
||||
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">
|
||||
<Link
|
||||
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" />
|
||||
<h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1>
|
||||
</Link>
|
||||
<div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6">
|
||||
{user.type === "student" &&
|
||||
badges.map((badge) => (
|
||||
<div
|
||||
key={badge.module}
|
||||
className={`${badge.achieved ? `bg-ielts-${badge.module}`: 'bg-mti-gray-anti-flash'} flex h-8 w-8 items-center justify-center rounded-full`}
|
||||
>
|
||||
{badge.icon()}
|
||||
</div>
|
||||
))}
|
||||
{/* OPEN TICKET SYSTEM */}
|
||||
<button
|
||||
className={clsx(
|
||||
"border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1",
|
||||
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white z-20"
|
||||
)}
|
||||
data-tip="Submit a help/feedback ticket"
|
||||
onClick={() => setIsTicketOpen(true)}
|
||||
>
|
||||
<BsQuestionCircleFill />
|
||||
</button>
|
||||
{user && (
|
||||
<MobileMenu 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">
|
||||
<Link 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" />
|
||||
<h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1>
|
||||
</Link>
|
||||
<div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6">
|
||||
{user.type === "student" &&
|
||||
badges.map((badge) => (
|
||||
<div
|
||||
key={badge.module}
|
||||
className={`${
|
||||
badge.achieved ? `bg-ielts-${badge.module}` : "bg-mti-gray-anti-flash"
|
||||
} flex h-8 w-8 items-center justify-center rounded-full`}>
|
||||
{badge.icon()}
|
||||
</div>
|
||||
))}
|
||||
{/* OPEN TICKET SYSTEM */}
|
||||
<button
|
||||
className={clsx(
|
||||
"border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1",
|
||||
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white z-20",
|
||||
)}
|
||||
data-tip="Submit a help/feedback ticket"
|
||||
onClick={() => setIsTicketOpen(true)}>
|
||||
<BsQuestionCircleFill />
|
||||
</button>
|
||||
|
||||
{showExpirationDate() && (
|
||||
<Link
|
||||
href={
|
||||
!!user.subscriptionExpirationDate && !disablePaymentPage
|
||||
? "/payment"
|
||||
: ""
|
||||
}
|
||||
data-tip="Expiry date"
|
||||
className={clsx(
|
||||
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none",
|
||||
"tooltip tooltip-bottom transition duration-300 ease-in-out",
|
||||
!user.subscriptionExpirationDate
|
||||
? "bg-mti-green-ultralight border-mti-green-light"
|
||||
: expirationDateColor(user.subscriptionExpirationDate),
|
||||
"border-mti-gray-platinum bg-white"
|
||||
)}
|
||||
>
|
||||
{!user.subscriptionExpirationDate && "Unlimited"}
|
||||
{user.subscriptionExpirationDate &&
|
||||
moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/profile"}
|
||||
className="-md:hidden flex items-center justify-end gap-6"
|
||||
>
|
||||
<img
|
||||
src={user.profilePicture}
|
||||
alt={user.name}
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
<span className="-md:hidden text-right">
|
||||
{user.type === "corporate"
|
||||
? `${user.corporateInformation?.companyInformation.name} |`
|
||||
: ""}{" "}
|
||||
{user.name} | {USER_TYPE_LABELS[user.type]}
|
||||
</span>
|
||||
</Link>
|
||||
<div
|
||||
className="cursor-pointer md:hidden"
|
||||
onClick={() => setIsMenuOpen(true)}
|
||||
>
|
||||
<BsList className="text-mti-purple-light h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
{focusMode && (
|
||||
<FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
|
||||
)}
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
{showExpirationDate() && (
|
||||
<Link
|
||||
href={!!user.subscriptionExpirationDate && !disablePaymentPage ? "/payment" : ""}
|
||||
data-tip="Expiry date"
|
||||
className={clsx(
|
||||
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none",
|
||||
"tooltip tooltip-bottom transition duration-300 ease-in-out",
|
||||
!user.subscriptionExpirationDate
|
||||
? "bg-mti-green-ultralight border-mti-green-light"
|
||||
: expirationDateColor(user.subscriptionExpirationDate),
|
||||
"border-mti-gray-platinum bg-white",
|
||||
)}>
|
||||
{!user.subscriptionExpirationDate && "Unlimited"}
|
||||
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
||||
</Link>
|
||||
)}
|
||||
<Link href={disableNavigation ? "" : "/profile"} className="-md:hidden flex items-center justify-end gap-6">
|
||||
<img src={user.profilePicture} alt={user.name} className="h-10 w-10 rounded-full object-cover" />
|
||||
<span className="-md:hidden text-right">
|
||||
{(user.type === "corporate" || user.type === "mastercorporate") && !!user.corporateInformation?.companyInformation?.name
|
||||
? `${user.corporateInformation?.companyInformation.name} |`
|
||||
: ""}{" "}
|
||||
{user.name} | {USER_TYPE_LABELS[user.type]}
|
||||
{user.type === "corporate" &&
|
||||
!!user.demographicInformation?.position &&
|
||||
` | ${user.demographicInformation?.position || "N/A"}`}
|
||||
</span>
|
||||
</Link>
|
||||
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
|
||||
<BsList className="text-mti-purple-light h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
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 {convertCamelCaseToReadable} from "@/utils/string";
|
||||
|
||||
@@ -29,8 +30,18 @@ export default function PermissionList({permissions}: Props) {
|
||||
columns: defaultColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const groupedData: {[key: string]: Row<Permission>[]} = table.getRowModel().rows.reduce((groups: {[key: string]: Row<Permission>[]}, row) => {
|
||||
const parent = row.original.topic;
|
||||
if (!groups[parent]) {
|
||||
groups[parent] = [];
|
||||
}
|
||||
groups[parent].push(row);
|
||||
return groups;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="w-full h-full">
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||
<thead>
|
||||
@@ -45,14 +56,23 @@ export default function PermissionList({permissions}: Props) {
|
||||
))}
|
||||
</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 items-center w-fit" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
{Object.keys(groupedData).map((parent) => (
|
||||
<React.Fragment key={parent}>
|
||||
<tr>
|
||||
<td className="px-2 py-2 items-center w-fit">
|
||||
<strong>{parent}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
{groupedData[parent].map((row, i) => (
|
||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tr>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
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,8 +13,9 @@ import {
|
||||
BsCurrencyDollar,
|
||||
BsClipboardData,
|
||||
BsFileLock,
|
||||
BsPeople,
|
||||
} from "react-icons/bs";
|
||||
import { CiDumbbell } from "react-icons/ci";
|
||||
import {CiDumbbell} from "react-icons/ci";
|
||||
import {RiLogoutBoxFill} from "react-icons/ri";
|
||||
import {SlPencil} from "react-icons/sl";
|
||||
import {FaAward} from "react-icons/fa";
|
||||
@@ -28,6 +29,7 @@ import usePreferencesStore from "@/stores/preferencesStore";
|
||||
import {User} from "@/interfaces/user";
|
||||
import useTicketsListener from "@/hooks/useTicketsListener";
|
||||
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
interface Props {
|
||||
path: string;
|
||||
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 {totalAssignedTickets} = useTicketsListener(user.id);
|
||||
const {permissions} = usePermissions(user.id);
|
||||
|
||||
const logout = async () => {
|
||||
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">
|
||||
<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} />
|
||||
)}
|
||||
{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} />
|
||||
)}
|
||||
{checkAccess(user, getTypesOfUser(["agent"]), "viewStats") && (
|
||||
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
|
||||
<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} />
|
||||
)}
|
||||
{checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && (
|
||||
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
|
||||
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={isMinimized} />
|
||||
)}
|
||||
{checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"], "viewPaymentRecords") && (
|
||||
{checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"], permissions, "viewPaymentRecords") && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCurrencyDollar}
|
||||
@@ -133,7 +139,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{checkAccess(user, ["admin", "developer", "agent"], "viewTickets") && (
|
||||
{checkAccess(user, ["admin", "developer", "agent"], permissions, "viewTickets") && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsClipboardData}
|
||||
@@ -144,38 +150,38 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
||||
badge={totalAssignedTickets}
|
||||
/>
|
||||
)}
|
||||
{checkAccess(user, ["developer", "admin"]) && (
|
||||
<>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCloudFill}
|
||||
label="Generation"
|
||||
path={path}
|
||||
keyPath="/generation"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsFileLock}
|
||||
label="Permissions"
|
||||
path={path}
|
||||
keyPath="/permissions"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
</>
|
||||
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCloudFill}
|
||||
label="Generation"
|
||||
path={path}
|
||||
keyPath="/generation"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate", "agent"]) && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsFileLock}
|
||||
label="Permissions"
|
||||
path={path}
|
||||
keyPath="/permissions"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<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={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" 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} />
|
||||
)}
|
||||
{checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && (
|
||||
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
|
||||
<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} />
|
||||
)}
|
||||
{checkAccess(user, getTypesOfUser(["student"])) && (
|
||||
@@ -206,7 +212,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
||||
)}
|
||||
</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
|
||||
role="button"
|
||||
tabIndex={1}
|
||||
|
||||
@@ -1,54 +1,81 @@
|
||||
import {FillBlanksExercise} from "@/interfaces/exam";
|
||||
import {FillBlanksExercise, FillBlanksMCOption, ShuffleMap} from "@/interfaces/exam";
|
||||
import clsx from "clsx";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import {CommonProps} from ".";
|
||||
import {Fragment} from "react";
|
||||
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 total = text.match(/({{\d+}})/g)?.length || 0;
|
||||
const correct = userSolutions.filter((x) => {
|
||||
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution.toLowerCase();
|
||||
const correct = correctUserSolutions!.filter((x) => {
|
||||
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
|
||||
if (!solution) return false;
|
||||
|
||||
const option = words.find((w) =>
|
||||
typeof w === "string" ? w.toLowerCase() === x.solution.toLowerCase() : w.letter.toLowerCase() === x.solution.toLowerCase(),
|
||||
);
|
||||
const option = words.find((w) => {
|
||||
if (typeof w === "string") {
|
||||
return w.toLowerCase() === x.solution.toLowerCase();
|
||||
} else if ("letter" in w) {
|
||||
return w.letter.toLowerCase() === x.solution.toLowerCase();
|
||||
} else {
|
||||
return w.id.toString() === x.id.toString();
|
||||
}
|
||||
});
|
||||
if (!option) return false;
|
||||
|
||||
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;
|
||||
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};
|
||||
};
|
||||
|
||||
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) => {
|
||||
return (
|
||||
<span>
|
||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||
const id = match.replaceAll(/[\{\}]/g, "");
|
||||
const userSolution = userSolutions.find((x) => x.id === id);
|
||||
const solution = solutions.find((x) => x.id === id)!;
|
||||
const questionId = match.replaceAll(/[\{\}]/g, "");
|
||||
const userSolution = correctUserSolutions!.find((x) => x.id.toString() === questionId.toString());
|
||||
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) {
|
||||
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 (
|
||||
<button
|
||||
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",
|
||||
)}>
|
||||
{solution?.solution}
|
||||
{answerText}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -56,23 +83,50 @@ export default function FillBlanksSolutions({
|
||||
const userSolutionWord = words.find((w) =>
|
||||
typeof w === "string"
|
||||
? 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 (
|
||||
<button
|
||||
className={clsx(
|
||||
"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",
|
||||
)}>
|
||||
{solution.solution}
|
||||
{solutionText}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (userSolutionText !== solution.solution) {
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<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",
|
||||
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
|
||||
)}>
|
||||
{solution.solution}
|
||||
{solutionText}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
@@ -99,18 +153,28 @@ export default function FillBlanksSolutions({
|
||||
};
|
||||
|
||||
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">
|
||||
<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">
|
||||
{userSolutions &&
|
||||
{correctUserSolutions &&
|
||||
text.split("\\n").map((line, index) => (
|
||||
<p key={index}>
|
||||
{renderLines(line)}
|
||||
@@ -138,18 +202,19 @@ export default function FillBlanksSolutions({
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||
className="max-w-[200px] w-full">
|
||||
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: userSolutions, score: calculateScore(), type})}
|
||||
onClick={() => onNext({exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type})}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,17 @@ export default function InteractiveSpeaking({
|
||||
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
|
||||
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(() => {
|
||||
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(
|
||||
@@ -44,7 +55,41 @@ export default function InteractiveSpeaking({
|
||||
}, [userSolutions]);
|
||||
|
||||
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)}>
|
||||
<>
|
||||
{userSolutions &&
|
||||
@@ -115,13 +160,13 @@ export default function InteractiveSpeaking({
|
||||
{userSolutions &&
|
||||
userSolutions.length > 0 &&
|
||||
userSolutions[0].evaluation &&
|
||||
userSolutions[0].evaluation[`transcript_${(index + 1)}`] &&
|
||||
userSolutions[0].evaluation[`fixed_text_${(index + 1)}`] && (
|
||||
userSolutions[0].evaluation[`transcript_${index + 1}`] &&
|
||||
userSolutions[0].evaluation[`fixed_text_${index + 1}`] && (
|
||||
<Button
|
||||
className="w-full max-w-[180px] !py-2 self-center"
|
||||
color="pink"
|
||||
variant="outline"
|
||||
onClick={() => setDiffNumber((index + 1))}>
|
||||
onClick={() => setDiffNumber(index + 1)}>
|
||||
View Correction
|
||||
</Button>
|
||||
)}
|
||||
@@ -132,19 +177,25 @@ export default function InteractiveSpeaking({
|
||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<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 grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||
|
||||
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}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{userSolutions[0].evaluation &&
|
||||
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length > 0 ? (
|
||||
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length > 0 ? (
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
||||
<Tab
|
||||
@@ -169,20 +220,26 @@ export default function InteractiveSpeaking({
|
||||
}>
|
||||
Evaluation
|
||||
</Tab>
|
||||
{Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).map((key, index) => (
|
||||
<Tab
|
||||
key={key}
|
||||
className={({ selected }) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||
)
|
||||
}>
|
||||
Recommended Answer<br />(Prompt {index + 1})
|
||||
</Tab>
|
||||
))}
|
||||
{Object.keys(userSolutions[0].evaluation)
|
||||
.filter((x) => x.startsWith("perfect_answer"))
|
||||
.map((key, index) => (
|
||||
<Tab
|
||||
key={key}
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected
|
||||
? "bg-white shadow"
|
||||
: "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||
)
|
||||
}>
|
||||
Recommended Answer
|
||||
<br />
|
||||
(Prompt {index + 1})
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||
@@ -193,10 +250,16 @@ export default function InteractiveSpeaking({
|
||||
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-2">
|
||||
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit")} key={key}>
|
||||
<div
|
||||
className={clsx(
|
||||
"bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit",
|
||||
)}
|
||||
key={key}>
|
||||
{key}: Level {grade}
|
||||
</div>
|
||||
{typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>}
|
||||
{typeof taskResponse !== "number" && (
|
||||
<span className="px-2 py-2">{taskResponse.comment}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -205,13 +268,18 @@ export default function InteractiveSpeaking({
|
||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
||||
</Tab.Panel>
|
||||
{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">
|
||||
<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")}
|
||||
</span>
|
||||
</Tab.Panel>
|
||||
))}
|
||||
{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">
|
||||
<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",
|
||||
)}
|
||||
</span>
|
||||
</Tab.Panel>
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
) : (
|
||||
@@ -257,6 +325,6 @@ export default function InteractiveSpeaking({
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import Icon from "@mdi/react";
|
||||
import {Fragment} from "react";
|
||||
import Button from "../Low/Button";
|
||||
import Xarrow from "react-xarrows";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
|
||||
function QuestionSolutionArea({
|
||||
question,
|
||||
@@ -61,6 +62,8 @@ export default function MatchSentencesSolutions({
|
||||
onNext,
|
||||
onBack,
|
||||
}: MatchSentencesExercise & CommonProps) {
|
||||
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
|
||||
|
||||
const calculateScore = () => {
|
||||
const total = sentences.length;
|
||||
const correct = userSolutions.filter(
|
||||
@@ -72,7 +75,25 @@ export default function MatchSentencesSolutions({
|
||||
};
|
||||
|
||||
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">
|
||||
<span className="text-sm w-full leading-6">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
@@ -112,7 +133,8 @@ export default function MatchSentencesSolutions({
|
||||
color="purple"
|
||||
variant="outline"
|
||||
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
|
||||
</Button>
|
||||
|
||||
@@ -123,6 +145,6 @@ export default function MatchSentencesSolutions({
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
||||
import {MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap} from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import clsx from "clsx";
|
||||
import {useEffect, useState} from "react";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import {CommonProps} from ".";
|
||||
import Button from "../Low/Button";
|
||||
import {v4} from "uuid";
|
||||
|
||||
function Question({
|
||||
id,
|
||||
@@ -15,56 +15,73 @@ function Question({
|
||||
options,
|
||||
userSolution,
|
||||
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
||||
const {userSolutions} = useExamStore((state) => state);
|
||||
|
||||
const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => {
|
||||
if (foundMap) return foundMap;
|
||||
return userSolution.shuffleMaps?.find((map) => map.questionID === id) || null;
|
||||
}, null as ShuffleMap | null);
|
||||
|
||||
const newSolution = questionShuffleMap ? questionShuffleMap?.map[solution] : solution;
|
||||
|
||||
const renderPrompt = (prompt: string) => {
|
||||
return reactStringReplace(prompt, /((<u>)[\w\s']+(<\/u>))/g, (match) => {
|
||||
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
|
||||
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
|
||||
return word.length > 0 ? <u>{word}</u> : null;
|
||||
});
|
||||
};
|
||||
|
||||
const optionColor = (option: string) => {
|
||||
if (option === solution && !userSolution) {
|
||||
return "!border-mti-gray-davy !text-mti-gray-davy";
|
||||
if (option === newSolution && !userSolution) {
|
||||
return "!bg-mti-gray-davy !text-white";
|
||||
}
|
||||
|
||||
if (option === solution) {
|
||||
return "!border-mti-purple-light !text-mti-purple-light";
|
||||
if (option === newSolution) {
|
||||
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 (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
{isNaN(Number(id)) ? (
|
||||
<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>
|
||||
)}
|
||||
<div className="grid grid-cols-4 gap-4 place-items-center">
|
||||
<div className="flex flex-wrap gap-4 justify-between">
|
||||
{variant === "image" &&
|
||||
options.map((option) => (
|
||||
<div
|
||||
key={option.id}
|
||||
key={option?.id}
|
||||
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",
|
||||
optionColor(option.id),
|
||||
"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),
|
||||
)}>
|
||||
<span className={clsx("text-sm", solution !== option.id && userSolution !== option.id && "opacity-50")}>{option.id}</span>
|
||||
<img src={option.src!} alt={`Option ${option.id}`} />
|
||||
<span className={clsx("text-sm", newSolution !== option?.id && userSolution !== option?.id && "opacity-50")}>
|
||||
{option?.id}
|
||||
</span>
|
||||
{"src" in option && <img src={option?.src!} alt={`Option ${option?.id}`} />}
|
||||
</div>
|
||||
))}
|
||||
{variant === "text" &&
|
||||
options.map((option) => (
|
||||
<div
|
||||
key={option.id}
|
||||
className={clsx("flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm", optionColor(option.id))}>
|
||||
<span className="font-semibold">{option.id}.</span>
|
||||
<span>{option.text}</span>
|
||||
key={option?.id}
|
||||
className={clsx(
|
||||
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none",
|
||||
optionColor(option!.id),
|
||||
)}>
|
||||
<span className="font-semibold">{option?.id}.</span>
|
||||
<span>{option?.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -73,23 +90,31 @@ function Question({
|
||||
}
|
||||
|
||||
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 total = questions.length;
|
||||
const correct = userSolutions.filter(
|
||||
(x) => questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false,
|
||||
).length;
|
||||
const questionShuffleMap = stats.find((x) => x.exercise == id)?.shuffleMaps;
|
||||
const correct = userSolutions.filter((x) => {
|
||||
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;
|
||||
|
||||
return {total, correct, missing};
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
if (questionIndex === questions.length - 1) {
|
||||
if (questionIndex + 1 >= questions.length - 1) {
|
||||
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
||||
} else {
|
||||
setQuestionIndex(questionIndex + 1);
|
||||
setQuestionIndex(questionIndex + 2);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -97,22 +122,49 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
|
||||
if (questionIndex === 0) {
|
||||
onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
||||
} else {
|
||||
setQuestionIndex(questionIndex - 1);
|
||||
setQuestionIndex(questionIndex - 2);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 w-full h-full mb-20">
|
||||
<div className="flex flex-col gap-2 mt-4 h-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||
<span className="text-xl font-semibold">{prompt}</span>
|
||||
{userSolutions && questionIndex < questions.length && (
|
||||
<Question
|
||||
{...questions[questionIndex]}
|
||||
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-between w-full gap-8">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={back}
|
||||
className="max-w-[200px] w-full"
|
||||
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full h-full mb-20 mt-4">
|
||||
<div className="flex flex-col gap-4 mt-2">
|
||||
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||
{/*<span className="text-xl font-semibold">{prompt}</span>*/}
|
||||
{userSolutions && questionIndex < questions.length && (
|
||||
<Question
|
||||
{...questions[questionIndex]}
|
||||
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{userSolutions && questionIndex + 1 < questions.length && (
|
||||
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||
<Question
|
||||
{...questions[questionIndex + 1]}
|
||||
userSolution={userSolutions.find((x) => questions[questionIndex + 1].id === x.question)?.option}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
<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 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
|
||||
</Button>
|
||||
|
||||
@@ -138,6 +195,6 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,8 +32,53 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
}
|
||||
}, [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 (
|
||||
<>
|
||||
<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)}>
|
||||
<>
|
||||
{userSolutions &&
|
||||
@@ -126,19 +171,25 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<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 grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||
|
||||
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}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{userSolutions[0].evaluation &&
|
||||
(userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? (
|
||||
(userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? (
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
||||
<Tab
|
||||
@@ -153,7 +204,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
General Feedback
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||
@@ -164,7 +215,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
Evaluation
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||
@@ -185,10 +236,16 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-2">
|
||||
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit")} key={key}>
|
||||
<div
|
||||
className={clsx(
|
||||
"bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit",
|
||||
)}
|
||||
key={key}>
|
||||
{key}: Level {grade}
|
||||
</div>
|
||||
{typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>}
|
||||
{typeof taskResponse !== "number" && (
|
||||
<span className="px-2 py-2">{taskResponse.comment}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -227,7 +284,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
onBack({
|
||||
exercise: id,
|
||||
solutions: userSolutions,
|
||||
score: { total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 },
|
||||
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
||||
type,
|
||||
})
|
||||
}
|
||||
@@ -252,6 +309,6 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ import reactStringReplace from "react-string-replace";
|
||||
import {CommonProps} from ".";
|
||||
import {Fragment} from "react";
|
||||
import Button from "../Low/Button";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
|
||||
type Solution = "true" | "false" | "not_given";
|
||||
|
||||
export default function TrueFalseSolution({prompt, type, id, questions, userSolutions, onNext, onBack}: TrueFalseExercise & CommonProps) {
|
||||
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
|
||||
|
||||
const calculateScore = () => {
|
||||
const total = questions.length || 0;
|
||||
const correct = userSolutions.filter(
|
||||
@@ -37,7 +40,25 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
||||
};
|
||||
|
||||
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">
|
||||
<span className="text-sm w-full leading-6">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
@@ -121,7 +142,8 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
||||
color="purple"
|
||||
variant="outline"
|
||||
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
|
||||
</Button>
|
||||
|
||||
@@ -132,6 +154,6 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import reactStringReplace from "react-string-replace";
|
||||
import {CommonProps} from ".";
|
||||
import {toast} from "react-toastify";
|
||||
import Button from "../Low/Button";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
|
||||
function Blank({
|
||||
id,
|
||||
@@ -49,15 +50,11 @@ function Blank({
|
||||
{userSolution && !isUserSolutionCorrect() && (
|
||||
<div
|
||||
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}>
|
||||
{userSolution}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())}
|
||||
placeholder={id}
|
||||
contentEditable={disabled}>
|
||||
<div className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())} contentEditable={disabled}>
|
||||
{!solutions ? userInput : solutions.join(" / ")}
|
||||
</div>
|
||||
</span>
|
||||
@@ -75,6 +72,8 @@ export default function WriteBlanksSolutions({
|
||||
onNext,
|
||||
onBack,
|
||||
}: WriteBlanksExercise & CommonProps) {
|
||||
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
|
||||
|
||||
const calculateScore = () => {
|
||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||
const correct = userSolutions.filter(
|
||||
@@ -106,7 +105,25 @@ export default function WriteBlanksSolutions({
|
||||
};
|
||||
|
||||
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">
|
||||
<span className="text-sm w-full leading-6">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
@@ -146,7 +163,8 @@ export default function WriteBlanksSolutions({
|
||||
color="purple"
|
||||
variant="outline"
|
||||
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
|
||||
</Button>
|
||||
|
||||
@@ -157,6 +175,6 @@ export default function WriteBlanksSolutions({
|
||||
Next
|
||||
</Button>
|
||||
</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 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 (
|
||||
<>
|
||||
<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 && (
|
||||
<Transition show={isModalOpen} as={Fragment}>
|
||||
<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" && (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<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 grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||
|
||||
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}
|
||||
</div>
|
||||
);
|
||||
@@ -194,10 +245,16 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
||||
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-2">
|
||||
<div className={clsx("bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 w-fit")} key={key}>
|
||||
<div
|
||||
className={clsx(
|
||||
"bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 w-fit",
|
||||
)}
|
||||
key={key}>
|
||||
{key}: Level {grade}
|
||||
</div>
|
||||
{typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>}
|
||||
{typeof taskResponse !== "number" && (
|
||||
<span className="px-2 py-2">{taskResponse.comment}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -263,6 +320,6 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
||||
Next
|
||||
</Button>
|
||||
</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,91 +1,43 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import React from "react";
|
||||
import ExerciseWalkthrough from "@/training/ExerciseWalkthrough";
|
||||
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
|
||||
const TrainingExercise: React.FC<ITrainingTip> = (trainingTip: ITrainingTip) => {
|
||||
const leftText = "<div class=\"container mx-auto px-4 overflow-x-auto\"><table class=\"min-w-full bg-white border border-gray-300\"><thead><tr class=\"bg-gray-100\"><th class=\"py-2 px-4 border-b font-semibold text-left\">Category</th><th class=\"py-2 px-4 border-b font-semibold text-left\">Option A</th><th class=\"py-2 px-4 border-b font-semibold text-left\">Option B</th></tr></thead><tbody><tr><td class=\"py-2 px-4 border-b font-medium\">Self</td><td class=\"py-2 px-4 border-b\">You need to take care of yourself and connect with the people around you.</td><td class=\"py-2 px-4 border-b\">Focus on your interests and talents and meet people who are like you.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Home</td><td class=\"py-2 px-4 border-b\">It's a good idea to paint your living room yellow.</td><td class=\"py-2 px-4 border-b\">You should arrange your home so that it makes you feel happy.</td></tr><tr><td class=\"py-2 px-4 border-b font-medium\">Financial Life</td><td class=\"py-2 px-4 border-b\">You can be happy if you have enough money, but don't want money too much.</td><td class=\"py-2 px-4 border-b\">If you waste money on things you don't need, you won't have enough money for things that you do need.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Social Life</td><td class=\"py-2 px-4 border-b\">A good group of friends can increase your happiness.</td><td class=\"py-2 px-4 border-b\">Researchers say that a happy friend can increase our mood by nine percent.</td></tr><tr><td class=\"py-2 px-4 border-b font-medium\">Workplace</td><td class=\"py-2 px-4 border-b\">You spend a lot of time at work, so you should like your workplace.</td><td class=\"py-2 px-4 border-b\">Your boss needs to be someone you enjoy working for.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Community</td><td class=\"py-2 px-4 border-b\">The place where you live is more important for happiness than anything else.</td><td class=\"py-2 px-4 border-b\">Live around people who have the same amount of money as you do.</td></tr></tbody></table></div>";
|
||||
const tip = {
|
||||
category: "Strategy",
|
||||
body: "<p>Look for <b>clues to the main idea</b> in the first (and sometimes second) sentence of a paragraph.</p>"
|
||||
}
|
||||
const question = "<div class=\"container mx-auto px-4 py-8\"><h2 class=\"text-2xl font-bold mb-4\">Identifying Main Ideas</h2><p class=\"text-lg leading-relaxed mb-6\">Read the statements below. Circle the main idea in each pair of statements (a or b).</p></div>";
|
||||
const rightTextData: WalkthroughConfigs[] = [
|
||||
{
|
||||
"html": "<div class='bg-blue-100 p-4 rounded-lg mb-4'><h2 class='text-xl font-bold mb-2'>Identifying Main Ideas</h2><p class='text-gray-700 leading-relaxed'>Let's analyze each pair of statements to determine which one represents the main idea. We'll focus on which statement is more general and encompasses the overall concept.</p></div>",
|
||||
"wordDelay": 200,
|
||||
"holdDelay": 5000,
|
||||
"highlight": []
|
||||
},
|
||||
{
|
||||
"html": "<div class='bg-green-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>1. Self</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You need to take care of yourself and connect with the people around you.</b></p><p class='mt-2'>This statement is more comprehensive, covering both self-care and social connections. Option B is more specific and could be considered a subset of A.</p></div>",
|
||||
"wordDelay": 200,
|
||||
"holdDelay": 8000,
|
||||
"highlight": ["You need to take care of yourself and connect with the people around you."]
|
||||
},
|
||||
{
|
||||
"html": "<div class='bg-yellow-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>2. Home</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>B. You should arrange your home so that it makes you feel happy.</b></p><p class='mt-2'>This statement is more general and applies to the entire home. Option A is a specific example that could fall under this broader concept.</p></div>",
|
||||
"wordDelay": 200,
|
||||
"holdDelay": 8000,
|
||||
"highlight": ["You should arrange your home so that it makes you feel happy."]
|
||||
},
|
||||
{
|
||||
"html": "<div class='bg-pink-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>3. Financial Life</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You can be happy if you have enough money, but don't want money too much.</b></p><p class='mt-2'>This statement provides a balanced view of money's role in happiness. Option B is more specific and could be seen as a consequence of wanting money too much.</p></div>",
|
||||
"wordDelay": 200,
|
||||
"holdDelay": 8000,
|
||||
"highlight": ["You can be happy if you have enough money, but don't want money too much."]
|
||||
},
|
||||
{
|
||||
"html": "<div class='bg-purple-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>4. Social Life</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. A good group of friends can increase your happiness.</b></p><p class='mt-2'>This statement is more general about the impact of friendships. Option B provides a specific statistic that supports this main idea.</p></div>",
|
||||
"wordDelay": 200,
|
||||
"holdDelay": 8000,
|
||||
"highlight": ["A good group of friends can increase your happiness."]
|
||||
},
|
||||
{
|
||||
"html": "<div class='bg-indigo-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>5. Workplace</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You spend a lot of time at work, so you should like your workplace.</b></p><p class='mt-2'>This statement covers the overall importance of workplace satisfaction. Option B focuses on one specific aspect (the boss) and is less comprehensive.</p></div>",
|
||||
"wordDelay": 200,
|
||||
"holdDelay": 8000,
|
||||
"highlight": ["You spend a lot of time at work, so you should like your workplace."]
|
||||
},
|
||||
{
|
||||
"html": "<div class='bg-red-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>6. Community</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. The place where you live is more important for happiness than anything else.</b></p><p class='mt-2'>While this statement might be debatable, it's more general and encompasses the overall importance of community. Option B is a specific suggestion about community demographics.</p></div>",
|
||||
"wordDelay": 200,
|
||||
"holdDelay": 8000,
|
||||
"highlight": ["The place where you live is more important for happiness than anything else."]
|
||||
},
|
||||
{
|
||||
"html": "<div class='bg-orange-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>Key Strategy</h3><p class='text-gray-700 leading-relaxed'>When identifying main ideas:</p><ul class='list-disc pl-5 space-y-2'><li>Look for broader, more encompassing statements</li><li>Consider which statement other ideas could fall under</li><li>Identify which statement provides a general principle rather than a specific example</li></ul></div>",
|
||||
"wordDelay": 200,
|
||||
"holdDelay": 8000,
|
||||
"highlight": []
|
||||
},
|
||||
{
|
||||
"html": "<div class='bg-teal-50 p-4 rounded-lg'><h3 class='text-lg font-semibold mb-2'>Helpful Tip</h3><p class='text-gray-700 leading-relaxed'>Remember to look for clues to the main idea in the first (and sometimes second) sentence of a paragraph. In this exercise, we applied this concept to pairs of statements. This approach can help you quickly identify the central theme or main point in various types of text.</p></div>",
|
||||
"wordDelay": 200,
|
||||
"holdDelay": 5000,
|
||||
"highlight": []
|
||||
const tip = {
|
||||
"category": "",
|
||||
"embedding": "",
|
||||
"text": "",
|
||||
"html": "",
|
||||
"id": "",
|
||||
"verified": true,
|
||||
"standalone": false,
|
||||
"exercise": {
|
||||
"question": "",
|
||||
"additional": "",
|
||||
"segments": []
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const mockTip: ITrainingTip = {
|
||||
id: "some random id",
|
||||
tipCategory: tip.category,
|
||||
tipHtml: tip.body,
|
||||
standalone: false,
|
||||
exercise: {
|
||||
question: question,
|
||||
highlightable: leftText,
|
||||
segments: rightTextData
|
||||
}
|
||||
}
|
||||
const mockTip: ITrainingTip = {
|
||||
id: "some random id",
|
||||
tipCategory: tip.category,
|
||||
tipHtml: tip.html,
|
||||
standalone: tip.standalone,
|
||||
exercise: {
|
||||
question: tip.exercise.question,
|
||||
additional: tip.exercise.additional,
|
||||
segments: tip.exercise.segments as WalkthroughConfigs[]
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col p-10">
|
||||
<ExerciseWalkthrough {...trainingTip}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const formattedTip = formatTip(mockTip);
|
||||
return (
|
||||
<ExerciseWalkthrough {...formatTip(trainingTip)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default TrainingExercise;
|
||||
@@ -1,287 +1,401 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { animated } from '@react-spring/web';
|
||||
import { FaRegCirclePlay, FaRegCircleStop } from "react-icons/fa6";
|
||||
import HighlightedContent from './AnimatedHighlight';
|
||||
import { ITrainingTip, SegmentRef, TimelineEvent } from './TrainingInterfaces';
|
||||
import HighlightContent from '../HighlightContent';
|
||||
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 [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false);
|
||||
const [currentTime, setCurrentTime] = useState<number>(0);
|
||||
const [walkthroughHtml, setWalkthroughHtml] = useState<string>('');
|
||||
const [highlightedPhrases, setHighlightedPhrases] = useState<string[]>([]);
|
||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||
const timelineRef = useRef<TimelineEvent[]>([]);
|
||||
const animationRef = useRef<number | null>(null);
|
||||
const segmentsRef = useRef<SegmentRef[]>([]);
|
||||
const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false);
|
||||
const [currentTime, setCurrentTime] = useState<number>(0);
|
||||
const [currentHighlightConfigs, setCurrentHighlightConfigs] = useState<HighlightConfig[]>([]);
|
||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||
const [currentSegmentIndex, setCurrentSegmentIndex] = useState<number>(0);
|
||||
const timelineRef = useRef<TimelineEvent[]>([]);
|
||||
const animationRef = useRef<number | null>(null);
|
||||
const segmentsRef = useRef<SegmentRef[]>([]);
|
||||
|
||||
const toggleAutoPlay = useCallback(() => {
|
||||
setIsAutoPlaying((prev) => {
|
||||
if (!prev && currentTime === getMaxTime()) {
|
||||
setCurrentTime(0);
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
}, [currentTime]);
|
||||
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 handleAnimationComplete = useCallback(() => {
|
||||
setIsAutoPlaying(false);
|
||||
}, []);
|
||||
const toggleAutoPlay = useCallback(() => {
|
||||
setIsAutoPlaying((prev) => {
|
||||
if (!prev && currentTime === getMaxTime()) {
|
||||
setCurrentTime(0);
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentTime]);
|
||||
|
||||
const handleResetAnimation = useCallback((newTime: number) => {
|
||||
setCurrentTime(newTime);
|
||||
}, []);
|
||||
const handleAnimationComplete = useCallback(() => {
|
||||
setIsAutoPlaying(false);
|
||||
}, []);
|
||||
|
||||
const getMaxTime = (): number => {
|
||||
return tip.exercise?.segments.reduce((sum, segment) =>
|
||||
sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0
|
||||
) ?? 0;
|
||||
};
|
||||
const handleResetAnimation = useCallback((newTime: number) => {
|
||||
setCurrentTime(newTime);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timeline: TimelineEvent[] = [];
|
||||
let currentTimePosition = 0;
|
||||
segmentsRef.current = [];
|
||||
const getMaxTime = (): number => {
|
||||
return tip.exercise?.segments.reduce((sum, segment) =>
|
||||
sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0
|
||||
) ?? 0;
|
||||
};
|
||||
|
||||
tip.exercise?.segments.forEach((segment, index) => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(segment.html, 'text/html');
|
||||
const words: string[] = [];
|
||||
const walkTree = (node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
words.push(...(node.textContent?.split(/\s+/).filter(word => word.length > 0) || []));
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
Array.from(node.childNodes).forEach(walkTree);
|
||||
}
|
||||
};
|
||||
walkTree(doc.body);
|
||||
useEffect(() => {
|
||||
const timeline: TimelineEvent[] = [];
|
||||
let currentTimePosition = 0;
|
||||
segmentsRef.current = [];
|
||||
const newHtmlStates: HtmlState[] = [];
|
||||
|
||||
const textDuration = words.length * segment.wordDelay;
|
||||
tip.exercise?.segments.forEach((segment, index) => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(segment.html, 'text/html');
|
||||
const words: string[] = [];
|
||||
const walkTree = (node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
words.push(...(node.textContent?.split(/\s+/).filter(word => word.length > 0) || []));
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
Array.from(node.childNodes).forEach(walkTree);
|
||||
}
|
||||
};
|
||||
walkTree(doc.body);
|
||||
|
||||
segmentsRef.current.push({
|
||||
...segment,
|
||||
words: words,
|
||||
startTime: currentTimePosition,
|
||||
endTime: currentTimePosition + textDuration
|
||||
});
|
||||
const textDuration = words.length * segment.wordDelay;
|
||||
|
||||
timeline.push({
|
||||
type: 'text',
|
||||
start: currentTimePosition,
|
||||
end: currentTimePosition + textDuration,
|
||||
segmentIndex: index
|
||||
});
|
||||
segmentsRef.current.push({
|
||||
...segment,
|
||||
words: words,
|
||||
startTime: currentTimePosition,
|
||||
endTime: currentTimePosition + textDuration
|
||||
});
|
||||
|
||||
currentTimePosition += textDuration;
|
||||
timeline.push({
|
||||
type: 'text',
|
||||
start: currentTimePosition,
|
||||
end: currentTimePosition + textDuration,
|
||||
segmentIndex: index
|
||||
});
|
||||
|
||||
timeline.push({
|
||||
type: 'highlight',
|
||||
start: currentTimePosition,
|
||||
end: currentTimePosition + segment.holdDelay,
|
||||
content: segment.highlight,
|
||||
segmentIndex: index
|
||||
});
|
||||
currentTimePosition += textDuration;
|
||||
|
||||
currentTimePosition += segment.holdDelay;
|
||||
});
|
||||
timeline.push({
|
||||
type: 'highlight',
|
||||
start: currentTimePosition,
|
||||
end: currentTimePosition + segment.holdDelay,
|
||||
content: segment.highlight,
|
||||
segmentIndex: index
|
||||
});
|
||||
|
||||
timelineRef.current = timeline;
|
||||
}, [tip.exercise?.segments]);
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
const updateText = useCallback(() => {
|
||||
const currentEvent = timelineRef.current.find(
|
||||
event => currentTime >= event.start && currentTime < event.end
|
||||
);
|
||||
currentTimePosition += segment.holdDelay;
|
||||
});
|
||||
|
||||
if (currentEvent) {
|
||||
if (currentEvent.type === 'text') {
|
||||
const segment = segmentsRef.current[currentEvent.segmentIndex];
|
||||
const elapsedTime = currentTime - currentEvent.start;
|
||||
const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const previousSegmentsHtml = segmentsRef.current
|
||||
.slice(0, currentEvent.segmentIndex)
|
||||
.map(seg => seg.html)
|
||||
.join('');
|
||||
timelineRef.current = timeline;
|
||||
setHtmlStates(newHtmlStates);
|
||||
}, [tip.exercise?.segments, questionHtml, additionalHtml, walkthroughHtml]);
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(segment.html, 'text/html');
|
||||
let wordCount = 0;
|
||||
const walkTree = (node: Node, action: (node: Node) => void): boolean => {
|
||||
if (node.nodeType === Node.TEXT_NODE && node.textContent) {
|
||||
const words = node.textContent.split(/(\s+)/).filter(word => word.length > 0);
|
||||
if (wordCount + words.filter(w => !/\s+/.test(w)).length <= wordsToShow) {
|
||||
action(node.cloneNode(true));
|
||||
wordCount += words.filter(w => !/\s+/.test(w)).length;
|
||||
} else {
|
||||
const remainingWords = wordsToShow - wordCount;
|
||||
const newTextContent = words.reduce((acc, word) => {
|
||||
if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) {
|
||||
acc.text += word;
|
||||
acc.nonSpaceWords++;
|
||||
} else if (/\s+/.test(word) || acc.nonSpaceWords < remainingWords) {
|
||||
acc.text += word;
|
||||
}
|
||||
return acc;
|
||||
}, { text: '', nonSpaceWords: 0 }).text;
|
||||
const newNode = node.cloneNode(false);
|
||||
newNode.textContent = newTextContent;
|
||||
action(newNode);
|
||||
wordCount = wordsToShow;
|
||||
}
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const clone = node.cloneNode(false);
|
||||
action(clone);
|
||||
Array.from(node.childNodes).some(child => {
|
||||
return walkTree(child, childNode => (clone as Node).appendChild(childNode));
|
||||
});
|
||||
}
|
||||
return wordCount >= wordsToShow;
|
||||
};
|
||||
const fragment = document.createDocumentFragment();
|
||||
walkTree(doc.body, node => fragment.appendChild(node));
|
||||
const updateText = useCallback(() => {
|
||||
const currentEvents = timelineRef.current.filter(
|
||||
event => currentTime >= event.start && currentTime <= event.end
|
||||
);
|
||||
|
||||
const serializer = new XMLSerializer();
|
||||
const currentSegmentHtml = Array.from(fragment.childNodes)
|
||||
.map(node => serializer.serializeToString(node))
|
||||
.join('');
|
||||
const newHtml = previousSegmentsHtml + currentSegmentHtml;
|
||||
if (currentTime < lastProcessedInsertTime.current) {
|
||||
const lastInsertEvent = timelineRef.current
|
||||
.filter(event => event.type === 'insert' && event.start <= currentTime)
|
||||
.pop();
|
||||
|
||||
setWalkthroughHtml(newHtml);
|
||||
setHighlightedPhrases([]);
|
||||
} else if (currentEvent.type === 'highlight') {
|
||||
const newHtml = segmentsRef.current
|
||||
.slice(0, currentEvent.segmentIndex + 1)
|
||||
.map(seg => seg.html)
|
||||
.join('');
|
||||
setWalkthroughHtml(newHtml);
|
||||
setHighlightedPhrases(currentEvent.content || []);
|
||||
}
|
||||
}
|
||||
}, [currentTime]);
|
||||
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('');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateText();
|
||||
}, [currentTime, updateText]);
|
||||
currentEvents.forEach(currentEvent => {
|
||||
if (currentEvent.type === 'text') {
|
||||
const segment = segmentsRef.current[currentEvent.segmentIndex];
|
||||
const elapsedTime = currentTime - currentEvent.start;
|
||||
const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAutoPlaying) {
|
||||
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
|
||||
if (lastEvent && currentTime >= lastEvent.end) {
|
||||
setCurrentTime(0);
|
||||
}
|
||||
setIsPlaying(true);
|
||||
} else {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}, [isAutoPlaying, currentTime]);
|
||||
const previousSegmentsHtml = segmentsRef.current
|
||||
.slice(0, currentEvent.segmentIndex)
|
||||
.map(seg => seg.html)
|
||||
.join('');
|
||||
|
||||
useEffect(() => {
|
||||
const animate = () => {
|
||||
if (isPlaying) {
|
||||
setCurrentTime((prevTime) => {
|
||||
const newTime = prevTime + 50;
|
||||
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
|
||||
if (lastEvent && newTime >= lastEvent.end) {
|
||||
setIsPlaying(false);
|
||||
handleAnimationComplete();
|
||||
return lastEvent.end;
|
||||
}
|
||||
return newTime;
|
||||
});
|
||||
}
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(segment.html, 'text/html');
|
||||
let wordCount = 0;
|
||||
const walkTree = (node: Node, action: (node: Node) => void): boolean => {
|
||||
if (node.nodeType === Node.TEXT_NODE && node.textContent) {
|
||||
const words = node.textContent.split(/(\s+)/).filter(word => word.length > 0);
|
||||
if (wordCount + words.filter(w => !/\s+/.test(w)).length <= wordsToShow) {
|
||||
action(node.cloneNode(true));
|
||||
wordCount += words.filter(w => !/\s+/.test(w)).length;
|
||||
} else {
|
||||
const remainingWords = wordsToShow - wordCount;
|
||||
const newTextContent = words.reduce((acc, word) => {
|
||||
if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) {
|
||||
acc.text += word;
|
||||
acc.nonSpaceWords++;
|
||||
} else if (/\s+/.test(word) || acc.nonSpaceWords < remainingWords) {
|
||||
acc.text += word;
|
||||
}
|
||||
return acc;
|
||||
}, { text: '', nonSpaceWords: 0 }).text;
|
||||
const newNode = node.cloneNode(false);
|
||||
newNode.textContent = newTextContent;
|
||||
action(newNode);
|
||||
wordCount = wordsToShow;
|
||||
}
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const clone = node.cloneNode(false);
|
||||
action(clone);
|
||||
Array.from(node.childNodes).some(child => {
|
||||
return walkTree(child, childNode => (clone as Node).appendChild(childNode));
|
||||
});
|
||||
}
|
||||
return wordCount >= wordsToShow;
|
||||
};
|
||||
const fragment = document.createDocumentFragment();
|
||||
walkTree(doc.body, node => fragment.appendChild(node));
|
||||
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
const serializer = new XMLSerializer();
|
||||
const currentSegmentHtml = Array.from(fragment.childNodes)
|
||||
.map(node => serializer.serializeToString(node))
|
||||
.join('');
|
||||
const newHtml = previousSegmentsHtml + currentSegmentHtml;
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}, [isPlaying, handleAnimationComplete]);
|
||||
setWalkthroughHtml(newHtml);
|
||||
setCurrentSegmentIndex(currentEvent.segmentIndex);
|
||||
setCurrentHighlightConfigs([]);
|
||||
} else if (currentEvent.type === 'highlight') {
|
||||
const newHtml = segmentsRef.current
|
||||
.slice(0, currentEvent.segmentIndex + 1)
|
||||
.map(seg => seg.html)
|
||||
.join('');
|
||||
setWalkthroughHtml(newHtml);
|
||||
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, htmlStates, tip.exercise?.question, tip.exercise?.additional]);
|
||||
|
||||
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTime = parseInt(e.target.value, 10);
|
||||
setCurrentTime(newTime);
|
||||
handleResetAnimation(newTime);
|
||||
};
|
||||
useEffect(() => {
|
||||
updateText();
|
||||
}, [currentTime, updateText]);
|
||||
|
||||
const handleSliderMouseDown = () => {
|
||||
setIsPlaying(false);
|
||||
};
|
||||
const insertHtmlContent = (prevHtml: string, config: InsertHtmlConfig): string => {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = prevHtml;
|
||||
|
||||
const handleSliderMouseUp = () => {
|
||||
if (isAutoPlaying) {
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (tip.standalone || !tip.exercise) {
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
<h1 className='text-xl font-bold text-red-600'>The exercise for this tip is not available yet!</h1>
|
||||
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4 mt-10">
|
||||
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
|
||||
<div className="text-gray-700" dangerouslySetInnerHTML={{ __html: tip.tipHtml }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return tempDiv.innerHTML;
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4">
|
||||
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
|
||||
<div className="text-gray-700" dangerouslySetInnerHTML={{ __html: tip.tipHtml }} />
|
||||
</div>
|
||||
<div className='flex flex-col space-y-4'>
|
||||
<div className='flex flex-row items-center space-x-4 py-4'>
|
||||
<button
|
||||
onClick={toggleAutoPlay}
|
||||
className="p-2 bg-blue-500 text-white rounded-full transition-colors duration-200 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
|
||||
aria-label={isAutoPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isAutoPlaying ? (
|
||||
<FaRegCircleStop className="w-6 h-6" />
|
||||
) : (
|
||||
<FaRegCirclePlay className="w-6 h-6" />
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={timelineRef.current.length > 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0}
|
||||
value={currentTime}
|
||||
onChange={handleSliderChange}
|
||||
onMouseDown={handleSliderMouseDown}
|
||||
onMouseUp={handleSliderMouseUp}
|
||||
onTouchStart={handleSliderMouseDown}
|
||||
onTouchEnd={handleSliderMouseUp}
|
||||
className='flex-grow'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4'>
|
||||
<div className='flex-1 bg-white p-6 rounded-lg shadow'>
|
||||
{/*<h2 className="text-xl font-bold mb-4">Question</h2>*/}
|
||||
<div className="mb-4" dangerouslySetInnerHTML={{ __html: tip.exercise.question }} />
|
||||
<HighlightedContent html={tip.exercise.highlightable} highlightPhrases={highlightedPhrases} />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<div className='bg-gray-50 rounded-lg shadow'>
|
||||
<div className='p-6 space-y-4'>
|
||||
<animated.div
|
||||
dangerouslySetInnerHTML={{ __html: walkthroughHtml }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
useEffect(() => {
|
||||
if (isAutoPlaying) {
|
||||
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
|
||||
if (lastEvent && currentTime >= lastEvent.end) {
|
||||
setCurrentTime(0);
|
||||
}
|
||||
setIsPlaying(true);
|
||||
} else {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}, [isAutoPlaying, currentTime]);
|
||||
|
||||
useEffect(() => {
|
||||
const animate = () => {
|
||||
if (isPlaying) {
|
||||
setCurrentTime((prevTime) => {
|
||||
const newTime = prevTime + 50;
|
||||
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
|
||||
if (lastEvent && newTime >= lastEvent.end) {
|
||||
setIsPlaying(false);
|
||||
handleAnimationComplete();
|
||||
return lastEvent.end;
|
||||
}
|
||||
return newTime;
|
||||
});
|
||||
}
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}, [isPlaying, handleAnimationComplete]);
|
||||
|
||||
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTime = parseInt(e.target.value, 10);
|
||||
setCurrentTime(newTime);
|
||||
handleResetAnimation(newTime);
|
||||
};
|
||||
|
||||
const handleSliderMouseDown = () => {
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
const handleSliderMouseUp = () => {
|
||||
if (isAutoPlaying) {
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
<Tip category={tip.tipCategory} html={tip.tipHtml} />
|
||||
{!tip.standalone && (
|
||||
<div className='flex flex-col space-y-4'>
|
||||
<div className='flex flex-row items-center space-x-4 py-4'>
|
||||
<button
|
||||
onClick={toggleAutoPlay}
|
||||
className="p-2 bg-blue-500 text-white rounded-full transition-colors duration-200 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
|
||||
aria-label={isAutoPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isAutoPlaying ? (
|
||||
<FaRegCircleStop className="w-6 h-6" />
|
||||
) : (
|
||||
<FaRegCirclePlay className="w-6 h-6" />
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={timelineRef.current.length > 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0}
|
||||
value={currentTime}
|
||||
onChange={handleSliderChange}
|
||||
onMouseDown={handleSliderMouseDown}
|
||||
onMouseUp={handleSliderMouseUp}
|
||||
onTouchStart={handleSliderMouseDown}
|
||||
onTouchEnd={handleSliderMouseUp}
|
||||
className='flex-grow'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 w-full'>
|
||||
<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 space-y-6'>
|
||||
<div className="container mx-auto px-4">
|
||||
<div id="question-container" className="border p-6 rounded-lg shadow-md">
|
||||
<HighlightContent
|
||||
html={questionHtml}
|
||||
highlightConfigs={currentHighlightConfigs}
|
||||
contentType="question"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{tip.exercise?.additional && (<div className="container mx-auto px-4">
|
||||
<div id="additional-container" className="border p-6 rounded-lg shadow-md">
|
||||
<HighlightContent
|
||||
html={additionalHtml}
|
||||
highlightConfigs={currentHighlightConfigs}
|
||||
contentType="additional"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<div className='bg-gray-50 rounded-lg shadow'>
|
||||
<div id="segment-container" className='p-6 space-y-4'>
|
||||
<animated.div>
|
||||
<HighlightContent
|
||||
html={walkthroughHtml}
|
||||
highlightConfigs={currentHighlightConfigs.filter(config =>
|
||||
config.targets.includes('segment') || config.targets.includes('all')
|
||||
)}
|
||||
contentType="segment"
|
||||
currentSegmentIndex={currentSegmentIndex}
|
||||
/>
|
||||
</animated.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExerciseWalkthrough;
|
||||
export default ExerciseWalkthrough;
|
||||
201
src/components/TrainingContent/FormatTip.ts
Normal file
201
src/components/TrainingContent/FormatTip.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { ITrainingTip, WalkthroughConfigs } from "./TrainingInterfaces";
|
||||
|
||||
const colorOptions = [
|
||||
'red', 'blue', 'green', 'purple', 'pink', 'indigo', 'teal', 'orange', 'cyan', 'emerald', 'sky', 'violet', 'fuchsia', 'rose', 'lime', 'slate'
|
||||
]
|
||||
|
||||
const getRandomColors = (count: number) => {
|
||||
const shuffled = [...colorOptions].sort(() => 0.5 - Math.random());
|
||||
return shuffled.slice(0, count);
|
||||
};
|
||||
|
||||
const classMap = {
|
||||
"mainDiv": {
|
||||
"tip": "flex-col gap-2",
|
||||
"question": "flex-col gap-2",
|
||||
"additional": "flex-col gap-8",
|
||||
"segment": "p-4 rounded-lg mb-4 flex flex-col gap-2"
|
||||
},
|
||||
"h2": {
|
||||
"tip": "mb-4 font-semibold text-lg",
|
||||
"question": "text-lg font-semibold mb-4",
|
||||
"additional": "text-2xl font-semibold mb-4",
|
||||
"segment": "text-xl font-semibold"
|
||||
}
|
||||
}
|
||||
|
||||
const setClass = (element: Element, style: string) => {
|
||||
element.setAttribute('class', style)
|
||||
}
|
||||
|
||||
|
||||
// DON'T OVERRIDE DIV AND SPAN STYLES
|
||||
const processHtml = (section: string, html: string, color: string) => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
const mainDiv = doc.body.firstElementChild;
|
||||
if (mainDiv && mainDiv.tagName === 'DIV') {
|
||||
if (section === "segment") {
|
||||
setClass(mainDiv, `bg-${color}-100 ${classMap["mainDiv"][section]}`);
|
||||
} else {
|
||||
setClass(mainDiv, classMap["mainDiv"][section as keyof typeof classMap["mainDiv"]]);
|
||||
}
|
||||
}
|
||||
|
||||
doc.querySelectorAll('h1').forEach(e => {
|
||||
if (section === "additional") {
|
||||
setClass(e, 'text-4xl font-bold mb-6')
|
||||
} else {
|
||||
setClass(e, 'text-xl font-semibold mb-4');
|
||||
}
|
||||
});
|
||||
|
||||
doc.querySelectorAll('h2').forEach(e => {
|
||||
setClass(e, classMap["h2"][section as keyof typeof classMap["h2"]])
|
||||
});
|
||||
|
||||
doc.querySelectorAll('h3').forEach(e => {
|
||||
e.setAttribute('class', 'text-lg font-semibold mb-4')
|
||||
})
|
||||
|
||||
doc.querySelectorAll('p').forEach(e => {
|
||||
if (section === "segment") {
|
||||
setClass(e, 'text-gray-700 leading-relaxed')
|
||||
} else {
|
||||
setClass(e, 'mb-4');
|
||||
}
|
||||
});
|
||||
|
||||
doc.querySelectorAll('label').forEach(e => {
|
||||
if (section === "additional") {
|
||||
setClass(e, 'font-semibold');
|
||||
} else {
|
||||
setClass(e, 'min-w-[16px] mr-1 font-semibold');
|
||||
}
|
||||
});
|
||||
|
||||
doc.querySelectorAll('ul').forEach(e => {
|
||||
const hasLabel = Array.from(e.querySelectorAll('li')).some(li => li.querySelector('label'));
|
||||
if (hasLabel) {
|
||||
e.setAttribute('class', 'list-none space-y-2');
|
||||
} else {
|
||||
e.setAttribute('class', `list-disc pl-5 space-y-2`);
|
||||
}
|
||||
});
|
||||
|
||||
doc.querySelectorAll('ol').forEach(e => {
|
||||
e.setAttribute('class', 'list-decimal pl-5 space-y-2');
|
||||
})
|
||||
|
||||
doc.querySelectorAll('hz-row').forEach(e => {
|
||||
e.setAttribute('class', `flex flex-row items-center mb-4 gap-2`);
|
||||
})
|
||||
|
||||
if (section === "segment") {
|
||||
doc.querySelectorAll('b').forEach(e => {
|
||||
e.setAttribute('class', `text-${color}-700`);
|
||||
});
|
||||
}
|
||||
|
||||
doc.querySelectorAll('section').forEach(e => {
|
||||
e.setAttribute('class', `mb-8`);
|
||||
});
|
||||
|
||||
doc.querySelectorAll('option-box').forEach(e => {
|
||||
e.setAttribute('class', `flex justify-center min-w-[32px] min-h-6 bg-gray-200 rounded`);
|
||||
});
|
||||
|
||||
doc.querySelectorAll('option-box-grow').forEach(e => {
|
||||
e.setAttribute('class', 'flex flex-grow ml-2 w-10 min-h-6 bg-gray-200 rounded px-4 py-2');
|
||||
})
|
||||
|
||||
doc.querySelectorAll('option-box-blank').forEach(e => {
|
||||
e.setAttribute('class', 'min-w-[32px] min-h-[32px] border border-gray-300 text-center mr-3 flex justify-center items-center');
|
||||
})
|
||||
|
||||
doc.querySelectorAll('option-card').forEach(e => {
|
||||
e.setAttribute('class', 'bg-gray-100 rounded-lg flex flex-col p-4')
|
||||
})
|
||||
|
||||
doc.querySelectorAll('footer').forEach(e => {
|
||||
e.setAttribute('class', `flex flex-col gap-2 text-sm`);
|
||||
});
|
||||
|
||||
doc.querySelectorAll('single-line').forEach(e => {
|
||||
e.setAttribute('class', `border-b border-black w-full h-4 inline-block`);
|
||||
})
|
||||
|
||||
doc.querySelectorAll('padded-line').forEach(e => {
|
||||
e.setAttribute('class', `my-2 inline-block w-full`);
|
||||
})
|
||||
|
||||
doc.querySelectorAll('table').forEach(table => {
|
||||
table.setAttribute('class', 'min-w-full bg-white border border-gray-300')
|
||||
|
||||
table.querySelectorAll('thead tr').forEach(tr => {
|
||||
tr.setAttribute('class', 'bg-gray-100');
|
||||
});
|
||||
|
||||
table.querySelectorAll('th').forEach(th => {
|
||||
th.setAttribute('class', 'py-2 px-4 border-b font-semibold text-left');
|
||||
});
|
||||
|
||||
table.querySelectorAll('tbody tr').forEach((tr, index) => {
|
||||
if (index % 2 === 1) {
|
||||
tr.setAttribute('class', 'bg-gray-50');
|
||||
}
|
||||
});
|
||||
|
||||
table.querySelectorAll('td').forEach(td => {
|
||||
if (td === td.parentElement?.firstElementChild) {
|
||||
td.setAttribute('class', 'py-2 px-4 border-b font-medium');
|
||||
} else {
|
||||
td.setAttribute('class', 'py-2 px-4 border-b');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
doc.querySelectorAll('blockquote').forEach(e => {
|
||||
setClass(e, `flex w-full justify-center ${section === "segment" ? "" : "mb-4"}`)
|
||||
})
|
||||
|
||||
doc.querySelectorAll('items-between').forEach(e => {
|
||||
setClass(e, 'flex flex-row justify-between mb-4')
|
||||
})
|
||||
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
const formatTip = (tip: ITrainingTip): ITrainingTip => {
|
||||
if (tip.exercise && tip.exercise.segments) {
|
||||
const colors = getRandomColors(tip.exercise.segments.length);
|
||||
|
||||
const processedSegments: WalkthroughConfigs[] = tip.exercise.segments.map((segment, index) => ({
|
||||
...segment,
|
||||
html: processHtml("segment", segment.html, colors[index])
|
||||
}));
|
||||
|
||||
return {
|
||||
id: tip.id,
|
||||
tipCategory: tip.tipCategory,
|
||||
tipHtml: processHtml("tip", tip.tipHtml, ""),
|
||||
standalone: tip.standalone,
|
||||
exercise: {
|
||||
question: processHtml("question", tip.exercise.question, ""),
|
||||
additional: tip.exercise.additional ? processHtml("additional", tip.exercise.additional, "") : undefined,
|
||||
segments: processedSegments
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: tip.id,
|
||||
tipCategory: tip.tipCategory,
|
||||
tipHtml: processHtml("tip", tip.tipHtml, ""),
|
||||
standalone: tip.standalone,
|
||||
exercise: undefined
|
||||
};
|
||||
};
|
||||
|
||||
export default formatTip;
|
||||
83
src/components/TrainingContent/Tip.tsx
Normal file
83
src/components/TrainingContent/Tip.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { FaChessKnight, FaLink, FaPen } from 'react-icons/fa';
|
||||
import { IoLanguage } from 'react-icons/io5';
|
||||
import { MdOutlineCategory } from 'react-icons/md';
|
||||
import { GiSkills } from 'react-icons/gi';
|
||||
import { BiBookReader } from 'react-icons/bi';
|
||||
|
||||
type CategoryConfig = {
|
||||
[key: string]: {
|
||||
headerColor: string;
|
||||
bodyColor: string;
|
||||
textColor: string;
|
||||
icon: any;
|
||||
}
|
||||
};
|
||||
|
||||
const categoryConfig : CategoryConfig = {
|
||||
'Strategy': {
|
||||
headerColor: 'bg-yellow-400',
|
||||
bodyColor: 'bg-yellow-100',
|
||||
textColor: 'text-yellow-900',
|
||||
icon: FaChessKnight
|
||||
},
|
||||
'Word Partners': {
|
||||
headerColor: 'bg-purple-700',
|
||||
bodyColor: 'bg-purple-200',
|
||||
textColor: 'text-purple-900',
|
||||
icon: MdOutlineCategory
|
||||
},
|
||||
'Word Link': {
|
||||
headerColor: 'bg-green-600',
|
||||
bodyColor: 'bg-green-100',
|
||||
textColor: 'text-green-900',
|
||||
icon: FaLink
|
||||
},
|
||||
'CT Focus': {
|
||||
headerColor: 'bg-purple-700',
|
||||
bodyColor: 'bg-purple-200',
|
||||
textColor: 'text-purple-900',
|
||||
icon: GiSkills
|
||||
},
|
||||
'Reading Skill': {
|
||||
headerColor: 'bg-orange-200',
|
||||
bodyColor: 'bg-orange-100',
|
||||
textColor: 'text-orange-900',
|
||||
icon: BiBookReader
|
||||
},
|
||||
'Language for Writing': {
|
||||
headerColor: 'bg-orange-200',
|
||||
bodyColor: 'bg-orange-100',
|
||||
textColor: 'text-orange-900',
|
||||
icon: IoLanguage
|
||||
},
|
||||
'Writing Skill': {
|
||||
headerColor: 'bg-orange-200',
|
||||
bodyColor: 'bg-orange-100',
|
||||
textColor: 'text-orange-900',
|
||||
icon: FaPen
|
||||
}
|
||||
};
|
||||
|
||||
const Tip: React.FC<{ category: string; html: string }> = ({ category, html }) => {
|
||||
|
||||
const { headerColor, bodyColor, textColor, icon: Icon } = useMemo(() =>
|
||||
categoryConfig[category] || categoryConfig['Strategy'], [category]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg overflow-hidden shadow-md mb-4">
|
||||
<div className={`px-4 py-3 ${headerColor}`}>
|
||||
<h2 className="font-bold text-white text-xl flex items-center">
|
||||
<Icon className="ml-2 mr-2" size={24} />
|
||||
{category === "CT Focus" ? "Critical Thinking" : category}
|
||||
</h2>
|
||||
</div>
|
||||
<div className={`p-6 ${bodyColor}`}>
|
||||
<p className={`text-lg ${textColor}`} dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tip;
|
||||
@@ -3,6 +3,7 @@ import { Stat } from "@/interfaces/user";
|
||||
export interface ITrainingContent {
|
||||
id: string;
|
||||
created_at: number;
|
||||
user: string;
|
||||
exams: {
|
||||
id: string;
|
||||
date: number;
|
||||
@@ -28,7 +29,7 @@ export interface ITrainingTip {
|
||||
standalone: boolean;
|
||||
exercise?: {
|
||||
question: string;
|
||||
highlightable: string;
|
||||
additional?: string;
|
||||
segments: WalkthroughConfigs[]
|
||||
}
|
||||
}
|
||||
@@ -37,16 +38,31 @@ export interface WalkthroughConfigs {
|
||||
html: string;
|
||||
wordDelay: 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 {
|
||||
type: 'text' | 'highlight';
|
||||
type: 'text' | 'highlight' | 'insert';
|
||||
start: number;
|
||||
end: number;
|
||||
segmentIndex: number;
|
||||
content?: string[];
|
||||
content?: HighlightConfig[] | InsertHtmlConfig[];
|
||||
}
|
||||
|
||||
export interface SegmentRef extends WalkthroughConfigs {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
31
src/components/ui/popover.tsx
Normal file
31
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border border-neutral-200 bg-white p-4 text-neutral-950 shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
@@ -1,91 +1,91 @@
|
||||
import { Type } from "@/interfaces/user";
|
||||
import {Type} from "@/interfaces/user";
|
||||
|
||||
export const PERMISSIONS = {
|
||||
generateCode: {
|
||||
student: ["corporate", "developer", "admin", "mastercorporate"],
|
||||
teacher: ["corporate", "developer", "admin", "mastercorporate"],
|
||||
corporate: ["admin", "developer"],
|
||||
mastercorporate: ["admin", "developer"],
|
||||
generateCode: {
|
||||
student: ["corporate", "developer", "admin", "mastercorporate"],
|
||||
teacher: ["corporate", "developer", "admin", "mastercorporate"],
|
||||
corporate: ["admin", "developer"],
|
||||
mastercorporate: ["admin", "developer"],
|
||||
|
||||
admin: ["developer", "admin"],
|
||||
agent: ["developer", "admin"],
|
||||
developer: ["developer"],
|
||||
},
|
||||
deleteUser: {
|
||||
student: {
|
||||
perm: "deleteStudent",
|
||||
list: ["corporate", "developer", "admin", "mastercorporate"],
|
||||
},
|
||||
teacher: {
|
||||
perm: "deleteTeacher",
|
||||
list: ["corporate", "developer", "admin", "mastercorporate"],
|
||||
},
|
||||
corporate: {
|
||||
perm: "deleteCorporate",
|
||||
list: ["admin", "developer"],
|
||||
},
|
||||
mastercorporate: {
|
||||
perm: undefined,
|
||||
list: ["admin", "developer"],
|
||||
},
|
||||
admin: ["developer", "admin"],
|
||||
agent: ["developer", "admin"],
|
||||
developer: ["developer"],
|
||||
},
|
||||
deleteUser: {
|
||||
student: {
|
||||
perm: "deleteStudent",
|
||||
list: ["corporate", "developer", "admin", "mastercorporate"],
|
||||
},
|
||||
teacher: {
|
||||
perm: "deleteTeacher",
|
||||
list: ["corporate", "developer", "admin", "mastercorporate"],
|
||||
},
|
||||
corporate: {
|
||||
perm: "deleteCorporate",
|
||||
list: ["admin", "developer"],
|
||||
},
|
||||
mastercorporate: {
|
||||
perm: undefined,
|
||||
list: ["admin", "developer"],
|
||||
},
|
||||
|
||||
admin: {
|
||||
perm: "deleteAdmin",
|
||||
list: ["developer", "admin"],
|
||||
},
|
||||
agent: {
|
||||
perm: "deleteCountryManager",
|
||||
list: ["developer", "admin"],
|
||||
},
|
||||
developer: {
|
||||
perm: undefined,
|
||||
list: ["developer"],
|
||||
},
|
||||
},
|
||||
updateUser: {
|
||||
student: {
|
||||
perm: "editStudent",
|
||||
list: ["developer", "admin"],
|
||||
},
|
||||
teacher: {
|
||||
perm: "editTeacher",
|
||||
list: ["developer", "admin"],
|
||||
},
|
||||
admin: {
|
||||
perm: "deleteAdmin",
|
||||
list: ["developer", "admin"],
|
||||
},
|
||||
agent: {
|
||||
perm: "deleteCountryManager",
|
||||
list: ["developer", "admin"],
|
||||
},
|
||||
developer: {
|
||||
perm: undefined,
|
||||
list: ["developer"],
|
||||
},
|
||||
},
|
||||
updateUser: {
|
||||
student: {
|
||||
perm: "editStudent",
|
||||
list: ["developer", "admin", "corporate", "mastercorporate", "teacher"],
|
||||
},
|
||||
teacher: {
|
||||
perm: "editTeacher",
|
||||
list: ["developer", "admin", "corporate", "mastercorporate"],
|
||||
},
|
||||
|
||||
corporate: {
|
||||
perm: "editCorporate",
|
||||
list: ["admin", "developer"],
|
||||
},
|
||||
mastercorporate: {
|
||||
perm: undefined,
|
||||
list: ["admin", "developer"],
|
||||
},
|
||||
corporate: {
|
||||
perm: "editCorporate",
|
||||
list: ["developer", "admin", "mastercorporate"],
|
||||
},
|
||||
mastercorporate: {
|
||||
perm: undefined,
|
||||
list: ["admin", "developer"],
|
||||
},
|
||||
|
||||
admin: {
|
||||
perm: "editAdmin",
|
||||
list: ["developer", "admin"],
|
||||
},
|
||||
admin: {
|
||||
perm: "editAdmin",
|
||||
list: ["developer", "admin"],
|
||||
},
|
||||
|
||||
agent: {
|
||||
perm: "editCountryManager",
|
||||
list: ["developer", "admin"],
|
||||
},
|
||||
developer: {
|
||||
perm: undefined,
|
||||
list: ["developer"],
|
||||
},
|
||||
},
|
||||
updateExpiryDate: {
|
||||
student: ["developer", "admin"],
|
||||
teacher: ["developer", "admin"],
|
||||
corporate: ["admin", "developer"],
|
||||
mastercorporate: ["admin", "developer"],
|
||||
agent: {
|
||||
perm: "editCountryManager",
|
||||
list: ["developer", "admin"],
|
||||
},
|
||||
developer: {
|
||||
perm: undefined,
|
||||
list: ["developer"],
|
||||
},
|
||||
},
|
||||
updateExpiryDate: {
|
||||
student: ["developer", "admin"],
|
||||
teacher: ["developer", "admin"],
|
||||
corporate: ["admin", "developer"],
|
||||
mastercorporate: ["admin", "developer"],
|
||||
|
||||
admin: ["developer", "admin"],
|
||||
agent: ["developer", "admin"],
|
||||
developer: ["developer"],
|
||||
},
|
||||
examManagement: {
|
||||
delete: ["developer", "admin"],
|
||||
},
|
||||
admin: ["developer", "admin"],
|
||||
agent: ["developer", "admin"],
|
||||
developer: ["developer"],
|
||||
},
|
||||
examManagement: {
|
||||
delete: ["developer", "admin"],
|
||||
},
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,13 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Modal from "@/components/Modal";
|
||||
import useStats from "@/hooks/useStats";
|
||||
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import { User } from "@/interfaces/user";
|
||||
import {Stat, User} from "@/interfaces/user";
|
||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||
import { dateSorter } from "@/utils";
|
||||
import {dateSorter} from "@/utils";
|
||||
import moment from "moment";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
BsArrowLeft,
|
||||
BsPersonFill,
|
||||
BsBank,
|
||||
BsCurrencyDollar,
|
||||
} from "react-icons/bs";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsArrowLeft, BsPersonFill, BsBank, BsCurrencyDollar} from "react-icons/bs";
|
||||
import UserCard from "@/components/UserCard";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
|
||||
@@ -20,276 +15,235 @@ import IconCard from "./IconCard";
|
||||
import usePaymentStatusUsers from "@/hooks/usePaymentStatusUsers";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default function AgentDashboard({ user }: Props) {
|
||||
const [page, setPage] = useState("");
|
||||
const [selectedUser, setSelectedUser] = useState<User>();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
export default function AgentDashboard({user}: Props) {
|
||||
const [page, setPage] = useState("");
|
||||
const [selectedUser, setSelectedUser] = useState<User>();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const { stats } = useStats();
|
||||
const { users, reload } = useUsers();
|
||||
const { groups } = useGroups(user.id);
|
||||
const { pending, done } = usePaymentStatusUsers();
|
||||
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
||||
const {users, reload} = useUsers();
|
||||
const {pending, done} = usePaymentStatusUsers();
|
||||
|
||||
useEffect(() => {
|
||||
setShowModal(!!selectedUser && page === "");
|
||||
}, [selectedUser, page]);
|
||||
useEffect(() => {
|
||||
setShowModal(!!selectedUser && page === "");
|
||||
}, [selectedUser, page]);
|
||||
|
||||
const corporateFilter = (user: User) => user.type === "corporate";
|
||||
const referredCorporateFilter = (x: User) =>
|
||||
x.type === "corporate" &&
|
||||
!!x.corporateInformation &&
|
||||
x.corporateInformation.referralAgent === user.id;
|
||||
const inactiveReferredCorporateFilter = (x: User) =>
|
||||
referredCorporateFilter(x) &&
|
||||
(x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
||||
const corporateFilter = (user: User) => user.type === "corporate";
|
||||
const referredCorporateFilter = (x: User) =>
|
||||
x.type === "corporate" && !!x.corporateInformation && x.corporateInformation.referralAgent === user.id;
|
||||
const inactiveReferredCorporateFilter = (x: User) =>
|
||||
referredCorporateFilter(x) && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
||||
|
||||
const UserDisplay = ({
|
||||
displayUser,
|
||||
allowClick = true,
|
||||
}: {
|
||||
displayUser: User;
|
||||
allowClick?: boolean;
|
||||
}) => (
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<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.type === "corporate"
|
||||
? displayUser.corporateInformation?.companyInformation?.name ||
|
||||
displayUser.name
|
||||
: displayUser.name}
|
||||
</span>
|
||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const UserDisplay = ({displayUser, allowClick = true}: {displayUser: User; allowClick?: boolean}) => (
|
||||
<div
|
||||
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">
|
||||
<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.type === "corporate"
|
||||
? displayUser.corporateInformation?.companyInformation?.name || displayUser.name
|
||||
: displayUser.name}
|
||||
</span>
|
||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ReferredCorporateList = () => {
|
||||
return (
|
||||
<UserList
|
||||
user={user}
|
||||
filters={[referredCorporateFilter]}
|
||||
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">
|
||||
Referred Corporate ({total})
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const ReferredCorporateList = () => {
|
||||
return (
|
||||
<UserList
|
||||
user={user}
|
||||
filters={[referredCorporateFilter]}
|
||||
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">Referred Corporate ({total})</h2>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const InactiveReferredCorporateList = () => {
|
||||
return (
|
||||
<UserList
|
||||
user={user}
|
||||
filters={[inactiveReferredCorporateFilter]}
|
||||
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">
|
||||
Inactive Referred Corporate ({total})
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const InactiveReferredCorporateList = () => {
|
||||
return (
|
||||
<UserList
|
||||
user={user}
|
||||
filters={[inactiveReferredCorporateFilter]}
|
||||
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">Inactive Referred Corporate ({total})</h2>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const CorporateList = () => {
|
||||
const filter = (x: User) => x.type === "corporate";
|
||||
const CorporateList = () => {
|
||||
const filter = (x: User) => x.type === "corporate";
|
||||
|
||||
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">Corporate ({total})</h2>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
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">Corporate ({total})</h2>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const CorporatePaidStatusList = ({ paid }: { paid: Boolean }) => {
|
||||
const list = paid ? done : pending;
|
||||
const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
|
||||
const CorporatePaidStatusList = ({paid}: {paid: Boolean}) => {
|
||||
const list = paid ? done : pending;
|
||||
const filter = (x: User) => x.type === "corporate" && list.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">
|
||||
{paid ? "Payment Done" : "Pending Payment"} ({total})
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
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">
|
||||
{paid ? "Payment Done" : "Pending Payment"} ({total})
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const DefaultDashboard = () => (
|
||||
<>
|
||||
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:gap-4 text-center">
|
||||
<IconCard
|
||||
onClick={() => setPage("referredCorporate")}
|
||||
Icon={BsBank}
|
||||
label="Referred Corporate"
|
||||
value={users.filter(referredCorporateFilter).length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("inactiveReferredCorporate")}
|
||||
Icon={BsBank}
|
||||
label="Inactive Referred Corporate"
|
||||
value={users.filter(inactiveReferredCorporateFilter).length}
|
||||
color="rose"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("corporate")}
|
||||
Icon={BsBank}
|
||||
label="Corporate"
|
||||
value={users.filter(corporateFilter).length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("paymentdone")}
|
||||
Icon={BsCurrencyDollar}
|
||||
label="Payment Done"
|
||||
value={done.length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("paymentpending")}
|
||||
Icon={BsCurrencyDollar}
|
||||
label="Pending Payment"
|
||||
value={pending.length}
|
||||
color="rose"
|
||||
/>
|
||||
</section>
|
||||
const DefaultDashboard = () => (
|
||||
<>
|
||||
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:gap-4 text-center">
|
||||
<IconCard
|
||||
onClick={() => setPage("referredCorporate")}
|
||||
Icon={BsBank}
|
||||
label="Referred Corporate"
|
||||
value={users.filter(referredCorporateFilter).length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("inactiveReferredCorporate")}
|
||||
Icon={BsBank}
|
||||
label="Inactive Referred Corporate"
|
||||
value={users.filter(inactiveReferredCorporateFilter).length}
|
||||
color="rose"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("corporate")}
|
||||
Icon={BsBank}
|
||||
label="Corporate"
|
||||
value={users.filter(corporateFilter).length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard onClick={() => setPage("paymentdone")} Icon={BsCurrencyDollar} label="Payment Done" value={done.length} color="purple" />
|
||||
<IconCard
|
||||
onClick={() => setPage("paymentpending")}
|
||||
Icon={BsCurrencyDollar}
|
||||
label="Pending Payment"
|
||||
value={pending.length}
|
||||
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 Referred Corporate</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(referredCorporateFilter)
|
||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} displayUser={x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Latest corporate</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(corporateFilter)
|
||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} displayUser={x} allowClick={false} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Referenced corporate expiring in 1 month</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(
|
||||
(x) =>
|
||||
referredCorporateFilter(x) &&
|
||||
moment().isAfter(
|
||||
moment(x.subscriptionExpirationDate).subtract(30, "days")
|
||||
) &&
|
||||
moment().isBefore(moment(x.subscriptionExpirationDate))
|
||||
)
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} displayUser={x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</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 Referred Corporate</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(referredCorporateFilter)
|
||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} displayUser={x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Latest corporate</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(corporateFilter)
|
||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} displayUser={x} allowClick={false} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Referenced corporate expiring in 1 month</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(
|
||||
(x) =>
|
||||
referredCorporateFilter(x) &&
|
||||
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
||||
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
||||
)
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} displayUser={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"
|
||||
? () => setPage("students")
|
||||
: undefined
|
||||
}
|
||||
onViewTeachers={
|
||||
selectedUser.type === "corporate"
|
||||
? () => setPage("teachers")
|
||||
: undefined
|
||||
}
|
||||
user={selectedUser}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Modal>
|
||||
{page === "referredCorporate" && <ReferredCorporateList />}
|
||||
{page === "corporate" && <CorporateList />}
|
||||
{page === "inactiveReferredCorporate" && (
|
||||
<InactiveReferredCorporateList />
|
||||
)}
|
||||
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
|
||||
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
|
||||
{page === "" && <DefaultDashboard />}
|
||||
</>
|
||||
);
|
||||
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" ? () => setPage("students") : undefined
|
||||
}
|
||||
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
|
||||
user={selectedUser}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Modal>
|
||||
{page === "referredCorporate" && <ReferredCorporateList />}
|
||||
{page === "corporate" && <CorporateList />}
|
||||
{page === "inactiveReferredCorporate" && <InactiveReferredCorporateList />}
|
||||
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
|
||||
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
|
||||
{page === "" && <DefaultDashboard />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,13 +10,18 @@ import {usePDFDownload} from "@/hooks/usePDFDownload";
|
||||
import {useAssignmentArchive} from "@/hooks/useAssignmentArchive";
|
||||
import {uniqBy} from "lodash";
|
||||
import {useAssignmentUnarchive} from "@/hooks/useAssignmentUnarchive";
|
||||
import {useAssignmentRelease} from "@/hooks/useAssignmentRelease";
|
||||
import {getUserName} from "@/utils/users";
|
||||
import {User} from "@/interfaces/user";
|
||||
|
||||
interface Props {
|
||||
users: User[];
|
||||
onClick?: () => void;
|
||||
allowDownload?: boolean;
|
||||
reload?: Function;
|
||||
allowArchive?: boolean;
|
||||
allowUnarchive?: boolean;
|
||||
allowExcelDownload?: boolean;
|
||||
}
|
||||
|
||||
export default function AssignmentCard({
|
||||
@@ -34,10 +39,16 @@ export default function AssignmentCard({
|
||||
reload,
|
||||
allowArchive,
|
||||
allowUnarchive,
|
||||
allowExcelDownload,
|
||||
users,
|
||||
released,
|
||||
}: Assignment & Props) {
|
||||
const renderPdfIcon = usePDFDownload("assignments");
|
||||
const renderExcelIcon = usePDFDownload("assignments", "excel");
|
||||
const renderArchiveIcon = useAssignmentArchive(id, reload);
|
||||
const renderUnarchiveIcon = useAssignmentUnarchive(id, reload);
|
||||
const renderReleaseIcon = useAssignmentRelease(id, reload);
|
||||
|
||||
|
||||
const calculateAverageModuleScore = (module: Module) => {
|
||||
const resultModuleBandScores = results.map((r) => {
|
||||
@@ -51,6 +62,30 @@ export default function AssignmentCard({
|
||||
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 (
|
||||
<div
|
||||
onClick={onClick}
|
||||
@@ -59,9 +94,11 @@ export default function AssignmentCard({
|
||||
<div className="flex flex-row justify-between">
|
||||
<h3 className="text-xl font-semibold">{name}</h3>
|
||||
<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")}
|
||||
{allowUnarchive && archived && renderUnarchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
||||
{!released && renderReleaseIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
||||
</div>
|
||||
</div>
|
||||
<ProgressBar
|
||||
@@ -72,13 +109,16 @@ export default function AssignmentCard({
|
||||
textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"}
|
||||
/>
|
||||
</div>
|
||||
<span className="flex justify-between gap-1">
|
||||
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
<span>-</span>
|
||||
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="flex justify-between gap-1">
|
||||
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
<span>-</span>
|
||||
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</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">
|
||||
{uniqBy(exams, (x) => x.module).map(({module}) => (
|
||||
{uniqModules.map(({module}) => (
|
||||
<div
|
||||
key={module}
|
||||
className={clsx(
|
||||
|
||||
@@ -2,7 +2,7 @@ import Input from "@/components/Low/Input";
|
||||
import Modal from "@/components/Modal";
|
||||
import {Module} from "@/interfaces";
|
||||
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 {generate} from "random-words";
|
||||
import {capitalize} from "lodash";
|
||||
@@ -21,22 +21,40 @@ import Checkbox from "@/components/Low/Checkbox";
|
||||
import {InstructorGender, Variant} from "@/interfaces/exam";
|
||||
import Select from "@/components/Low/Select";
|
||||
import useExams from "@/hooks/useExams";
|
||||
import {useListSearch} from "@/hooks/useListSearch";
|
||||
|
||||
interface Props {
|
||||
isCreating: boolean;
|
||||
assigner: string;
|
||||
users: User[];
|
||||
user: User;
|
||||
groups: Group[];
|
||||
assignment?: Assignment;
|
||||
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 [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 [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>(
|
||||
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");
|
||||
// creates a new exam for each assignee or just one exam for all assignees
|
||||
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
|
||||
const [released, setReleased] = useState<boolean>(assignment?.released || false);
|
||||
|
||||
const [autoStart, setAutostart] = useState<boolean>(assignment?.autoStart || false);
|
||||
const [autoStartDate, setAutoStartDate] = useState<Date | null>(assignment ? moment(assignment.autoStartDate).toDate() : new Date());
|
||||
|
||||
const [useRandomExams, setUseRandomExams] = useState(true);
|
||||
const [examIDs, setExamIDs] = useState<{id: string; module: Module}[]>([]);
|
||||
|
||||
const {exams} = useExams();
|
||||
|
||||
const userStudents = useMemo(() => users.filter((x) => x.type === "student"), [users]);
|
||||
const userTeachers = useMemo(() => users.filter((x) => x.type === "teacher"), [users]);
|
||||
|
||||
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(() => {
|
||||
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module)));
|
||||
}, [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]));
|
||||
};
|
||||
|
||||
const toggleTeacher = (user: User) => {
|
||||
setTeachers((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id]));
|
||||
};
|
||||
|
||||
const createAssignment = () => {
|
||||
setIsLoading(true);
|
||||
|
||||
@@ -73,8 +126,12 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
endDate,
|
||||
selectedModules,
|
||||
generateMultiple,
|
||||
teachers,
|
||||
variant,
|
||||
instructorGender,
|
||||
released,
|
||||
autoStart,
|
||||
autoStartDate,
|
||||
})
|
||||
.then(() => {
|
||||
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 (
|
||||
<Modal isOpen={isCreating} onClose={cancelCreation} title="New Assignment">
|
||||
<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
|
||||
onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : 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-2",
|
||||
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">
|
||||
@@ -131,7 +205,6 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : 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-2",
|
||||
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">
|
||||
@@ -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("listening") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
||||
</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
|
||||
onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : 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-2",
|
||||
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">
|
||||
@@ -165,7 +257,6 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : 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("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">
|
||||
@@ -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("speaking") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
|
||||
</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>
|
||||
|
||||
<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="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
|
||||
className={clsx(
|
||||
"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)}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{selectedModules.includes("speaking") && (
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor's Gender</label>
|
||||
<Select
|
||||
value={{value: instructorGender, label: capitalize(instructorGender)}}
|
||||
value={{
|
||||
value: instructorGender,
|
||||
label: capitalize(instructorGender),
|
||||
}}
|
||||
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)}
|
||||
disabled={!selectedModules.includes("speaking") || !!assignment}
|
||||
options={[
|
||||
@@ -285,9 +376,9 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
</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>
|
||||
<div className="flex gap-4 overflow-x-scroll scrollbar-hide">
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{groups.map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
@@ -309,8 +400,11 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{renderStudentSearch()}
|
||||
|
||||
<div className="flex flex-wrap -md:justify-center gap-4">
|
||||
{users.map((user) => (
|
||||
{studentRows.map((user) => (
|
||||
<div
|
||||
onClick={() => toggleAssignee(user)}
|
||||
className={clsx(
|
||||
@@ -340,29 +434,145 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
</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>
|
||||
<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"))}>
|
||||
Full length exams
|
||||
</Checkbox>
|
||||
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
|
||||
Generate different exams
|
||||
</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 className="flex gap-4 w-full justify-end">
|
||||
<Button className="w-full max-w-[200px]" variant="outline" onClick={cancelCreation} disabled={isLoading} isLoading={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
{assignment && (
|
||||
<Button
|
||||
className="w-full max-w-[200px]"
|
||||
color="red"
|
||||
variant="outline"
|
||||
onClick={deleteAssignment}
|
||||
disabled={isLoading}
|
||||
isLoading={isLoading}>
|
||||
Delete
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
className="w-full max-w-[200px]"
|
||||
color="green"
|
||||
variant="outline"
|
||||
onClick={startAssignment}
|
||||
disabled={isLoading || moment().isAfter(startDate)}
|
||||
isLoading={isLoading}>
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full max-w-[200px]"
|
||||
color="red"
|
||||
variant="outline"
|
||||
onClick={deleteAssignment}
|
||||
disabled={isLoading}
|
||||
isLoading={isLoading}>
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
disabled={
|
||||
@@ -371,7 +581,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
!startDate ||
|
||||
!endDate ||
|
||||
assignees.length === 0 ||
|
||||
(!!examIDs && examIDs.length < selectedModules.length)
|
||||
(!useRandomExams && examIDs.length < selectedModules.length)
|
||||
}
|
||||
className="w-full max-w-[200px]"
|
||||
onClick={createAssignment}
|
||||
|
||||
@@ -2,306 +2,433 @@ import Button from "@/components/Low/Button";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import Modal from "@/components/Modal";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Module} from "@/interfaces";
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
import {Stat, User} from "@/interfaces/user";
|
||||
import { Module } from "@/interfaces";
|
||||
import { Assignment } from "@/interfaces/results";
|
||||
import { Stat, User } from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import {sortByModule} from "@/utils/moduleUtils";
|
||||
import {calculateBandScore} from "@/utils/score";
|
||||
import {convertToUserSolutions} from "@/utils/stats";
|
||||
import { getExamById } from "@/utils/exams";
|
||||
import { sortByModule } from "@/utils/moduleUtils";
|
||||
import { calculateBandScore } from "@/utils/score";
|
||||
import { convertToUserSolutions } from "@/utils/stats";
|
||||
import { getUserName } from "@/utils/users";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize, uniqBy} from "lodash";
|
||||
import { capitalize, uniqBy } from "lodash";
|
||||
import moment from "moment";
|
||||
import {useRouter} from "next/router";
|
||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
BsBook,
|
||||
BsClipboard,
|
||||
BsHeadphones,
|
||||
BsMegaphone,
|
||||
BsPen,
|
||||
} from "react-icons/bs";
|
||||
import { toast } from "react-toastify";
|
||||
import { futureAssignmentFilter } from "@/utils/assignments";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
assignment?: Assignment;
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
assignment?: Assignment;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
||||
const {users} = useUsers();
|
||||
const router = useRouter();
|
||||
export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
||||
const { users } = useUsers();
|
||||
const router = useRouter();
|
||||
|
||||
const setExams = useExamStore((state) => state.setExams);
|
||||
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
const setExams = useExamStore((state) => state.setExams);
|
||||
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
|
||||
const deleteAssignment = async () => {
|
||||
if (!confirm("Are you sure you want to delete this assignment?")) return;
|
||||
const deleteAssignment = async () => {
|
||||
if (!confirm("Are you sure you want to delete this assignment?")) return;
|
||||
|
||||
axios
|
||||
.delete(`/api/assignments/${assignment?.id}`)
|
||||
.then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`))
|
||||
.catch(() => toast.error("Something went wrong, please try again later."))
|
||||
.finally(onClose);
|
||||
};
|
||||
axios
|
||||
.delete(`/api/assignments/${assignment?.id}`)
|
||||
.then(() =>
|
||||
toast.success(
|
||||
`Successfully deleted the assignment "${assignment?.name}".`
|
||||
)
|
||||
)
|
||||
.catch(() => toast.error("Something went wrong, please try again later."))
|
||||
.finally(onClose);
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
const date = moment(parseInt(timestamp));
|
||||
const formatter = "YYYY/MM/DD - HH:mm";
|
||||
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!");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return date.format(formatter);
|
||||
};
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
const date = moment(parseInt(timestamp));
|
||||
const formatter = "YYYY/MM/DD - HH:mm";
|
||||
|
||||
const calculateAverageModuleScore = (module: Module) => {
|
||||
if (!assignment) return -1;
|
||||
return date.format(formatter);
|
||||
};
|
||||
|
||||
const resultModuleBandScores = assignment.results.map((r) => {
|
||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||
const calculateAverageModuleScore = (module: Module) => {
|
||||
if (!assignment) return -1;
|
||||
|
||||
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
||||
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
||||
return calculateBandScore(correct, total, module, r.type);
|
||||
});
|
||||
const resultModuleBandScores = assignment.results.map((r) => {
|
||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||
|
||||
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
|
||||
};
|
||||
const correct = moduleStats.reduce(
|
||||
(acc, curr) => acc + curr.score.correct,
|
||||
0
|
||||
);
|
||||
const total = moduleStats.reduce(
|
||||
(acc, curr) => acc + curr.score.total,
|
||||
0
|
||||
);
|
||||
return calculateBandScore(correct, total, module, r.type);
|
||||
});
|
||||
|
||||
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
||||
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,
|
||||
},
|
||||
};
|
||||
return resultModuleBandScores.length === 0
|
||||
? -1
|
||||
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
|
||||
assignment.results.length;
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
return Object.keys(scores)
|
||||
.filter((x) => scores[x as Module].total > 0)
|
||||
.map((x) => ({module: x as Module, ...scores[x as Module]}));
|
||||
};
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
|
||||
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
||||
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
||||
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
||||
return Object.keys(scores)
|
||||
.filter((x) => scores[x as Module].total > 0)
|
||||
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
||||
};
|
||||
|
||||
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||
module: x.module,
|
||||
level: calculateBandScore(x.correct, x.total, x.module, focus),
|
||||
}));
|
||||
const customContent = (
|
||||
stats: Stat[],
|
||||
user: string,
|
||||
focus: "academic" | "general"
|
||||
) => {
|
||||
const correct = stats.reduce(
|
||||
(accumulator, current) => accumulator + current.score.correct,
|
||||
0
|
||||
);
|
||||
const total = stats.reduce(
|
||||
(accumulator, current) => accumulator + current.score.total,
|
||||
0
|
||||
);
|
||||
const aggregatedScores = aggregateScoresByModule(stats).filter(
|
||||
(x) => x.total > 0
|
||||
);
|
||||
|
||||
const timeSpent = stats[0].timeSpent;
|
||||
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||
module: x.module,
|
||||
level: calculateBandScore(x.correct, x.total, x.module, focus),
|
||||
}));
|
||||
|
||||
const selectExam = () => {
|
||||
const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
|
||||
const timeSpent = stats[0].timeSpent;
|
||||
|
||||
Promise.all(examPromises).then((exams) => {
|
||||
if (exams.every((x) => !!x)) {
|
||||
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 selectExam = () => {
|
||||
const examPromises = uniqBy(stats, "exam").map((stat) =>
|
||||
getExamById(stat.module, stat.exam)
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<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">
|
||||
<span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
|
||||
{timeSpent && (
|
||||
<>
|
||||
<span className="md:hidden 2xl:flex">• </span>
|
||||
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={clsx(
|
||||
correct / total >= 0.7 && "text-mti-purple",
|
||||
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||
correct / total < 0.3 && "text-mti-rose",
|
||||
)}>
|
||||
Level{" "}
|
||||
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
Promise.all(examPromises).then((exams) => {
|
||||
if (exams.every((x) => !!x)) {
|
||||
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");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||
{aggregatedLevels.map(({module, level}) => (
|
||||
<div
|
||||
key={module}
|
||||
className={clsx(
|
||||
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
<span className="text-sm">{level.toFixed(1)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
const content = (
|
||||
<>
|
||||
<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">
|
||||
<span className="font-medium">
|
||||
{formatTimestamp(stats[0].date.toString())}
|
||||
</span>
|
||||
{timeSpent && (
|
||||
<>
|
||||
<span className="md:hidden 2xl:flex">• </span>
|
||||
<span className="text-sm">
|
||||
{Math.floor(timeSpent / 60)} minutes
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={clsx(
|
||||
correct / total >= 0.7 && "text-mti-purple",
|
||||
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||
correct / total < 0.3 && "text-mti-rose"
|
||||
)}
|
||||
>
|
||||
Level{" "}
|
||||
{(
|
||||
aggregatedLevels.reduce(
|
||||
(accumulator, current) => accumulator + current.level,
|
||||
0
|
||||
) / aggregatedLevels.length
|
||||
).toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span>
|
||||
{(() => {
|
||||
const student = users.find((u) => u.id === user);
|
||||
return `${student?.name} (${student?.email})`;
|
||||
})()}
|
||||
</span>
|
||||
<div
|
||||
key={user}
|
||||
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",
|
||||
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",
|
||||
)}
|
||||
onClick={selectExam}
|
||||
role="button">
|
||||
{content}
|
||||
</div>
|
||||
<div
|
||||
key={user}
|
||||
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",
|
||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||
correct / total < 0.3 && "hover:border-mti-rose",
|
||||
)}
|
||||
data-tip="Your screen size is too small to view previous exams."
|
||||
role="button">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||
{aggregatedLevels.map(({ module, level }) => (
|
||||
<div
|
||||
key={module}
|
||||
className={clsx(
|
||||
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level"
|
||||
)}
|
||||
>
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
<span className="text-sm">{level.toFixed(1)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
|
||||
<div className="mt-4 flex w-full flex-col gap-4">
|
||||
<ProgressBar
|
||||
color="purple"
|
||||
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
||||
className="h-6"
|
||||
textClassName={
|
||||
(assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"
|
||||
}
|
||||
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100}
|
||||
/>
|
||||
<div className="flex items-start gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
</div>
|
||||
<span>
|
||||
Assignees:{" "}
|
||||
{users
|
||||
.filter((u) => assignment?.assignees.includes(u.id))
|
||||
.map((u) => `${u.name} (${u.email})`)
|
||||
.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xl font-bold">Average Scores</span>
|
||||
<div className="-md:mt-2 flex w-full items-center gap-4">
|
||||
{assignment &&
|
||||
uniqBy(assignment.exams, (x) => x.module).map(({module}) => (
|
||||
<div
|
||||
data-tip={capitalize(module)}
|
||||
key={module}
|
||||
className={clsx(
|
||||
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
{calculateAverageModuleScore(module) > -1 && (
|
||||
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xl font-bold">
|
||||
Results ({assignment?.results.length}/{assignment?.assignees.length})
|
||||
</span>
|
||||
<div>
|
||||
{assignment && assignment?.results.length > 0 && (
|
||||
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
|
||||
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
|
||||
</div>
|
||||
)}
|
||||
{assignment && assignment?.results.length === 0 && <span className="ml-1 font-semibold">No results yet...</span>}
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span>
|
||||
{(() => {
|
||||
const student = users.find((u) => u.id === user);
|
||||
return `${student?.name} (${student?.email})`;
|
||||
})()}
|
||||
</span>
|
||||
<div
|
||||
key={user}
|
||||
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",
|
||||
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"
|
||||
)}
|
||||
onClick={selectExam}
|
||||
role="button"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
<div
|
||||
key={user}
|
||||
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",
|
||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||
correct / total >= 0.3 &&
|
||||
correct / total < 0.7 &&
|
||||
"hover:border-mti-red",
|
||||
correct / total < 0.3 && "hover:border-mti-rose"
|
||||
)}
|
||||
data-tip="Your screen size is too small to view previous exams."
|
||||
role="button"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
<div className="flex gap-4 w-full items-center justify-end">
|
||||
{assignment && (assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && (
|
||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={deleteAssignment}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onClose} className="w-full max-w-[200px]">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
const shouldRenderStart = () => {
|
||||
if (assignment) {
|
||||
if (futureAssignmentFilter(assignment)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
|
||||
<div className="mt-4 flex w-full flex-col gap-4">
|
||||
<ProgressBar
|
||||
color="purple"
|
||||
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
||||
className="h-6"
|
||||
textClassName={
|
||||
(assignment?.results.length || 0) /
|
||||
(assignment?.assignees.length || 1) <
|
||||
0.5
|
||||
? "!text-mti-gray-dim font-light"
|
||||
: "text-white"
|
||||
}
|
||||
percentage={
|
||||
((assignment?.results.length || 0) /
|
||||
(assignment?.assignees.length || 1)) *
|
||||
100
|
||||
}
|
||||
/>
|
||||
<div className="flex items-start gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span>
|
||||
Start Date:{" "}
|
||||
{moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}
|
||||
</span>
|
||||
<span>
|
||||
End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span>
|
||||
Assignees:{" "}
|
||||
{users
|
||||
.filter((u) => assignment?.assignees.includes(u.id))
|
||||
.map((u) => `${u.name} (${u.email})`)
|
||||
.join(", ")}
|
||||
</span>
|
||||
<span>
|
||||
Assigner:{" "}
|
||||
{getUserName(users.find((x) => x.id === assignment?.assigner))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xl font-bold">Average Scores</span>
|
||||
<div className="-md:mt-2 flex w-full items-center gap-4">
|
||||
{assignment &&
|
||||
uniqBy(assignment.exams, (x) => x.module).map(({ module }) => (
|
||||
<div
|
||||
data-tip={capitalize(module)}
|
||||
key={module}
|
||||
className={clsx(
|
||||
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level"
|
||||
)}
|
||||
>
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && (
|
||||
<BsHeadphones className="h-4 w-4" />
|
||||
)}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
{calculateAverageModuleScore(module) > -1 && (
|
||||
<span className="text-sm">
|
||||
{calculateAverageModuleScore(module).toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xl font-bold">
|
||||
Results ({assignment?.results.length}/{assignment?.assignees.length}
|
||||
)
|
||||
</span>
|
||||
<div>
|
||||
{assignment && assignment?.results.length > 0 && (
|
||||
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
|
||||
{assignment.results.map((r) =>
|
||||
customContent(r.stats, r.user, r.type)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{assignment && assignment?.results.length === 0 && (
|
||||
<span className="ml-1 font-semibold">No results yet...</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 w-full items-center justify-end">
|
||||
{assignment &&
|
||||
(assignment.results.length === assignment.assignees.length ||
|
||||
moment().isAfter(moment(assignment.endDate))) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
className="w-full max-w-[200px]"
|
||||
onClick={deleteAssignment}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
{/** if the assignment is not deemed as active yet, display start */}
|
||||
{shouldRenderStart() && (
|
||||
<Button
|
||||
variant="outline"
|
||||
color="green"
|
||||
className="w-full max-w-[200px]"
|
||||
onClick={startAssignment}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onClose} className="w-full max-w-[200px]">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,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,140 +1,96 @@
|
||||
import React from "react";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import React, {useMemo} from "react";
|
||||
import useUsers, { userHashStudent, userHashTeacher, userHashCorporate } from "@/hooks/useUsers";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import { User } from "@/interfaces/user";
|
||||
import {User} from "@/interfaces/user";
|
||||
import Select from "@/components/Low/Select";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import {
|
||||
BsBook,
|
||||
BsClipboard,
|
||||
BsHeadphones,
|
||||
BsMegaphone,
|
||||
BsPen,
|
||||
} from "react-icons/bs";
|
||||
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
||||
import { capitalize } from "lodash";
|
||||
import { getLevelLabel } from "@/utils/score";
|
||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||
import {capitalize} from "lodash";
|
||||
import {getLevelLabel} from "@/utils/score";
|
||||
|
||||
const Card = ({ user }: { user: User }) => {
|
||||
return (
|
||||
<div className="border-mti-gray-platinum flex flex-col h-fit w-full cursor-pointer flex-col gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-xl font-semibold">{user.name}</h3>
|
||||
</div>
|
||||
<div className="flex w-full gap-3 flex-wrap">
|
||||
{MODULE_ARRAY.map((module) => {
|
||||
const desiredLevel = user.desiredLevels[module] || 9;
|
||||
const level = user.levels[module] || 0;
|
||||
return (
|
||||
<div
|
||||
className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4 min-w-[250px]"
|
||||
key={module}
|
||||
>
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
|
||||
{module === "reading" && (
|
||||
<BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />
|
||||
)}
|
||||
{module === "listening" && (
|
||||
<BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />
|
||||
)}
|
||||
{module === "writing" && (
|
||||
<BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />
|
||||
)}
|
||||
{module === "speaking" && (
|
||||
<BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />
|
||||
)}
|
||||
{module === "level" && (
|
||||
<BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-col">
|
||||
<span className="text-sm font-bold md:font-extrabold w-full">
|
||||
{capitalize(module)}
|
||||
</span>
|
||||
<div className="text-mti-gray-dim text-sm font-normal">
|
||||
{module === "level" && (
|
||||
<span>
|
||||
English Level: {getLevelLabel(level).join(" / ")}
|
||||
</span>
|
||||
)}
|
||||
{module !== "level" && (
|
||||
<div className="flex flex-col">
|
||||
<span>Level {level} / Level 9</span>
|
||||
<span>Desired Level: {desiredLevel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:pl-14">
|
||||
<ProgressBar
|
||||
color={module}
|
||||
label=""
|
||||
mark={Math.round((desiredLevel * 100) / 9)}
|
||||
markLabel={`Desired Level: ${desiredLevel}`}
|
||||
percentage={Math.round((level * 100) / 9)}
|
||||
className="h-2 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const Card = ({user}: {user: User}) => {
|
||||
return (
|
||||
<div className="border-mti-gray-platinum flex flex-col h-fit w-full cursor-pointer gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-xl font-semibold">{user.name}</h3>
|
||||
</div>
|
||||
<div className="flex w-full gap-3 flex-wrap">
|
||||
{MODULE_ARRAY.map((module) => {
|
||||
const desiredLevel = user.desiredLevels[module] || 9;
|
||||
const level = user.levels[module] || 0;
|
||||
return (
|
||||
<div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4 min-w-[250px]" key={module}>
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
|
||||
{module === "reading" && <BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />}
|
||||
{module === "listening" && <BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />}
|
||||
{module === "writing" && <BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />}
|
||||
{module === "speaking" && <BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />}
|
||||
{module === "level" && <BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />}
|
||||
</div>
|
||||
<div className="flex w-full flex-col">
|
||||
<span className="text-sm font-bold md:font-extrabold w-full">{capitalize(module)}</span>
|
||||
<div className="text-mti-gray-dim text-sm font-normal">
|
||||
{module === "level" && <span>English Level: {getLevelLabel(level).join(" / ")}</span>}
|
||||
{module !== "level" && (
|
||||
<div className="flex flex-col">
|
||||
<span>Level {level} / Level 9</span>
|
||||
<span>Desired Level: {desiredLevel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:pl-14">
|
||||
<ProgressBar
|
||||
color={module}
|
||||
label=""
|
||||
mark={Math.round((desiredLevel * 100) / 9)}
|
||||
markLabel={`Desired Level: ${desiredLevel}`}
|
||||
percentage={Math.round((level * 100) / 9)}
|
||||
className="h-2 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CorporateStudentsLevels = () => {
|
||||
const { users } = useUsers();
|
||||
const { groups } = useGroups();
|
||||
const [corporateId, setCorporateId] = React.useState<string>("");
|
||||
|
||||
const corporateUsers = users.filter((u) => u.type === "corporate") as User[];
|
||||
const [corporateId, setCorporateId] = React.useState<string>("");
|
||||
const corporate =
|
||||
corporateUsers.find((u) => u.id === corporateId) || corporateUsers[0];
|
||||
const {users: students} = useUsers(userHashStudent);
|
||||
const {users: corporates} = useUsers(userHashCorporate);
|
||||
|
||||
const groupsFromCorporate = corporate
|
||||
? groups.filter((g) => g.admin === corporate.id)
|
||||
: [];
|
||||
const corporate = useMemo(() => corporates.find((u) => u.id === corporateId) || corporates[0], [corporates, corporateId]);
|
||||
|
||||
const groupsParticipants = groupsFromCorporate
|
||||
.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 (
|
||||
<>
|
||||
<Select
|
||||
options={corporateUsers.map((x: User) => ({
|
||||
value: x.id,
|
||||
label: `${x.name} - ${x.email}`,
|
||||
}))}
|
||||
value={corporate ? { value: corporate.id, label: corporate.name } : null}
|
||||
onChange={(value) => setCorporateId(value?.value!)}
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused
|
||||
? "#D5D9F0"
|
||||
: state.isSelected
|
||||
? "#7872BF"
|
||||
: "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
{groupsParticipants.map((u) => (
|
||||
<Card user={u} key={u.id} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
options={corporates.map((x: User) => ({
|
||||
value: x.id,
|
||||
label: `${x.name} - ${x.email}`,
|
||||
}))}
|
||||
value={corporate ? {value: corporate.id, label: corporate.name} : null}
|
||||
onChange={(value) => setCorporateId(value?.value!)}
|
||||
styles={{
|
||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
{students.map((u) => (
|
||||
<Card user={u} key={u.id} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CorporateStudentsLevels;
|
||||
|
||||
@@ -8,14 +8,17 @@ interface Props {
|
||||
color: "purple" | "rose" | "red" | "green";
|
||||
tooltip?: string;
|
||||
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} = {
|
||||
purple: "text-mti-purple-light",
|
||||
red: "text-mti-red-light",
|
||||
rose: "text-mti-rose-light",
|
||||
green: "text-mti-green-light",
|
||||
purple: "mti-purple-light",
|
||||
red: "mti-red-light",
|
||||
rose: "mti-rose-light",
|
||||
green: "mti-green-light",
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -24,12 +27,16 @@ export default function IconCard({Icon, label, value, color, tooltip, onClick}:
|
||||
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",
|
||||
tooltip && "tooltip tooltip-bottom",
|
||||
isSelected && `border border-solid border-${colorClasses[color]}`,
|
||||
className,
|
||||
)}
|
||||
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="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>
|
||||
</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 ProfileSummary from "@/components/ProfileSummary";
|
||||
import useAssignments from "@/hooks/useAssignments";
|
||||
import useGradingSystem from "@/hooks/useGrading";
|
||||
import useInvites from "@/hooks/useInvites";
|
||||
import useStats from "@/hooks/useStats";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||
import useUsers, { userHashStudent, userHashTeacher, userHashCorporate} from "@/hooks/useUsers";
|
||||
import {Invite} from "@/interfaces/invite";
|
||||
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 {getExamById} from "@/utils/exams";
|
||||
import {getUserCorporate} from "@/utils/groups";
|
||||
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 {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
|
||||
import {PayPalButtons} from "@paypal/react-paypal-js";
|
||||
@@ -23,22 +24,29 @@ import {capitalize} from "lodash";
|
||||
import moment from "moment";
|
||||
import Link from "next/link";
|
||||
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 {toast} from "react-toastify";
|
||||
import {activeAssignmentFilter} from "@/utils/assignments";
|
||||
import ModuleBadge from "@/components/ModuleBadge";
|
||||
import useSessions from "@/hooks/useSessions";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
linkedCorporate?: CorporateUser | MasterCorporateUser;
|
||||
}
|
||||
|
||||
export default function StudentDashboard({user}: Props) {
|
||||
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
|
||||
|
||||
const {stats} = useStats(user.id, !user?.id);
|
||||
const {users} = useUsers();
|
||||
export default function StudentDashboard({user, linkedCorporate}: Props) {
|
||||
const {gradingSystem} = useGradingSystem();
|
||||
const {sessions} = useSessions(user.id);
|
||||
const {data: stats} = useFilterRecordsByUser<Stat[]>(user.id, !user?.id);
|
||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: 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 setExams = useExamStore((state) => state.setExams);
|
||||
@@ -47,10 +55,6 @@ export default function StudentDashboard({user}: Props) {
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
const setAssignment = useExamStore((state) => state.setAssignment);
|
||||
|
||||
useEffect(() => {
|
||||
getUserCorporate(user.id).then(setCorporateUserToShow);
|
||||
}, [user]);
|
||||
|
||||
const startAssignment = (assignment: Assignment) => {
|
||||
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 (
|
||||
<>
|
||||
{corporateUserToShow && (
|
||||
{linkedCorporate && (
|
||||
<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>
|
||||
)}
|
||||
<ProfileSummary
|
||||
@@ -122,50 +128,32 @@ export default function StudentDashboard({user}: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||
{assignments.filter((a) => moment(a.endDate).isSameOrAfter(moment())).length === 0 &&
|
||||
"Assignments will appear here. It seems that for now there are no assignments for you."}
|
||||
{assignments
|
||||
.filter((a) => moment(a.endDate).isSameOrAfter(moment()))
|
||||
{studentAssignments.length === 0 && "Assignments will appear here. It seems that for now there are no assignments for you."}
|
||||
{studentAssignments
|
||||
.sort((a, b) => moment(a.startDate).diff(b.startDate))
|
||||
.map((assignment) => (
|
||||
<div
|
||||
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",
|
||||
)}
|
||||
key={assignment.id}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<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>-</span>
|
||||
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
</span>
|
||||
</div>
|
||||
<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
|
||||
.filter((e) => e.assignee === user.id)
|
||||
.map((e) => e.module)
|
||||
.sort(sortByModuleName)
|
||||
.map((module) => (
|
||||
<div
|
||||
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>
|
||||
<ModuleBadge className="scale-110 w-full" key={module} module={module} />
|
||||
))}
|
||||
</div>
|
||||
{!assignment.results.map((r) => r.user).includes(user.id) && (
|
||||
@@ -173,20 +161,24 @@ export default function StudentDashboard({user}: Props) {
|
||||
<div
|
||||
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">
|
||||
<Button
|
||||
disabled={moment(assignment.startDate).isAfter(moment())}
|
||||
className="h-full w-full !rounded-xl"
|
||||
variant="outline">
|
||||
<Button className="h-full w-full !rounded-xl" variant="outline">
|
||||
Start
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
data-tip="You have already started this assignment!"
|
||||
className={clsx(
|
||||
"-md:hidden h-full w-full max-w-[50%] cursor-pointer",
|
||||
sessions.filter((x) => x.assignment?.id === assignment.id).length > 0 && "tooltip",
|
||||
)}>
|
||||
<Button
|
||||
className={clsx("w-full h-full !rounded-xl")}
|
||||
onClick={() => startAssignment(assignment)}
|
||||
variant="outline"
|
||||
disabled={sessions.filter((x) => x.assignment?.id === assignment.id).length > 0}>
|
||||
Start
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
disabled={moment(assignment.startDate).isAfter(moment())}
|
||||
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
||||
onClick={() => startAssignment(assignment)}
|
||||
variant="outline">
|
||||
Start
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{assignment.results.map((r) => r.user).includes(user.id) && (
|
||||
@@ -243,7 +235,7 @@ export default function StudentDashboard({user}: Props) {
|
||||
<div className="flex w-full justify-between">
|
||||
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span>
|
||||
<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})`}
|
||||
</span>
|
||||
</div>
|
||||
@@ -252,9 +244,9 @@ export default function StudentDashboard({user}: Props) {
|
||||
<ProgressBar
|
||||
color={module}
|
||||
label=""
|
||||
mark={Math.round((desiredLevel * 100) / 9)}
|
||||
mark={module === "level" ? undefined : Math.round((desiredLevel * 100) / 9)}
|
||||
markLabel={`Desired Level: ${desiredLevel}`}
|
||||
percentage={Math.round((level * 100) / 9)}
|
||||
percentage={module === "level" ? level : Math.round((level * 100) / 9)}
|
||||
className="h-2 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,478 +1,326 @@
|
||||
/* 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 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 {dateSorter} from "@/utils";
|
||||
import moment from "moment";
|
||||
import { useEffect, useState } from "react";
|
||||
import {useEffect, useMemo, useState} from "react";
|
||||
import {
|
||||
BsArrowLeft,
|
||||
BsArrowRepeat,
|
||||
BsClipboard2Data,
|
||||
BsClipboard2DataFill,
|
||||
BsClipboard2Heart,
|
||||
BsClipboard2X,
|
||||
BsClipboardPulse,
|
||||
BsClock,
|
||||
BsEnvelopePaper,
|
||||
BsGlobeCentralSouthAsia,
|
||||
BsPaperclip,
|
||||
BsPeople,
|
||||
BsPerson,
|
||||
BsPersonAdd,
|
||||
BsPersonFill,
|
||||
BsPersonFillGear,
|
||||
BsPersonGear,
|
||||
BsPlus,
|
||||
BsRepeat,
|
||||
BsRepeat1,
|
||||
BsArrowLeft,
|
||||
BsArrowRepeat,
|
||||
BsClipboard2Data,
|
||||
BsClipboard2DataFill,
|
||||
BsClipboard2Heart,
|
||||
BsClipboard2X,
|
||||
BsClipboardPulse,
|
||||
BsClock,
|
||||
BsEnvelopePaper,
|
||||
BsGlobeCentralSouthAsia,
|
||||
BsPaperclip,
|
||||
BsPeople,
|
||||
BsPerson,
|
||||
BsPersonAdd,
|
||||
BsPersonFill,
|
||||
BsPersonFillGear,
|
||||
BsPersonGear,
|
||||
BsPlus,
|
||||
BsRepeat,
|
||||
BsRepeat1,
|
||||
} 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 {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 useAssignments from "@/hooks/useAssignments";
|
||||
import { Assignment } from "@/interfaces/results";
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
import AssignmentCard from "./AssignmentCard";
|
||||
import Button from "@/components/Low/Button";
|
||||
import clsx from "clsx";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import AssignmentCreator from "./AssignmentCreator";
|
||||
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 {
|
||||
user: User;
|
||||
user: User;
|
||||
linkedCorporate?: CorporateUser | MasterCorporateUser;
|
||||
}
|
||||
|
||||
export default function TeacherDashboard({ user }: Props) {
|
||||
const [page, setPage] = useState("");
|
||||
const [selectedUser, setSelectedUser] = useState<User>();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
|
||||
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
||||
const [corporateUserToShow, setCorporateUserToShow] =
|
||||
useState<CorporateUser>();
|
||||
const studentHash = {
|
||||
type: "student",
|
||||
orderBy: "registrationDate",
|
||||
size: 25,
|
||||
};
|
||||
|
||||
const { stats } = useStats();
|
||||
const { users, reload } = useUsers();
|
||||
const { groups } = useGroups(user.id);
|
||||
const {
|
||||
assignments,
|
||||
isLoading: isAssignmentsLoading,
|
||||
reload: reloadAssignments,
|
||||
} = useAssignments({ assigner: user.id });
|
||||
export default function TeacherDashboard({user, linkedCorporate}: Props) {
|
||||
const [selectedUser, setSelectedUser] = useState<User>();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setShowModal(!!selectedUser && page === "");
|
||||
}, [selectedUser, page]);
|
||||
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
||||
const {groups} = useGroups({adminAdmins: user.id});
|
||||
const {permissions} = usePermissions(user.id);
|
||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id});
|
||||
|
||||
useEffect(() => {
|
||||
getUserCorporate(user.id).then(setCorporateUserToShow);
|
||||
}, [user]);
|
||||
const {users: students, total: totalStudents, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(studentHash);
|
||||
|
||||
const studentFilter = (user: User) =>
|
||||
user.type === "student" &&
|
||||
groups.flatMap((g) => g.participants).includes(user.id);
|
||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||
const router = useRouter();
|
||||
|
||||
const getStatsByStudent = (user: User) =>
|
||||
stats.filter((s) => s.user === user.id);
|
||||
const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, 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>
|
||||
);
|
||||
useEffect(() => {
|
||||
setShowModal(!!selectedUser && router.asPath === "/#");
|
||||
}, [selectedUser, router.asPath]);
|
||||
|
||||
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));
|
||||
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.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 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);
|
||||
const GroupsList = () => {
|
||||
const filter = (x: Group) => x.admin === 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>
|
||||
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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
<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 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));
|
||||
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);
|
||||
};
|
||||
return calculateAverageLevel(levels);
|
||||
};
|
||||
|
||||
const AssignmentsPage = () => {
|
||||
const activeFilter = (a: Assignment) =>
|
||||
moment(a.endDate).isAfter(moment()) &&
|
||||
moment(a.startDate).isBefore(moment()) &&
|
||||
a.assignees.length > a.results.length;
|
||||
const pastFilter = (a: Assignment) =>
|
||||
(moment(a.endDate).isBefore(moment()) ||
|
||||
a.assignees.length === a.results.length) &&
|
||||
!a.archived;
|
||||
const archivedFilter = (a: Assignment) => a.archived;
|
||||
const futureFilter = (a: Assignment) =>
|
||||
moment(a.startDate).isAfter(moment());
|
||||
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}
|
||||
groups={assignmentsGroups}
|
||||
user={user}
|
||||
reloadAssignments={reloadAssignments}
|
||||
isLoading={isAssignmentsLoading}
|
||||
onBack={() => router.push("/")}
|
||||
/>
|
||||
);
|
||||
if (router.asPath === "/#groups") return <GroupsList />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AssignmentView
|
||||
isOpen={!!selectedAssignment && !isCreatingAssignment}
|
||||
onClose={() => {
|
||||
setSelectedAssignment(undefined);
|
||||
setIsCreatingAssignment(false);
|
||||
reloadAssignments();
|
||||
}}
|
||||
assignment={selectedAssignment}
|
||||
/>
|
||||
<AssignmentCreator
|
||||
assignment={selectedAssignment}
|
||||
groups={groups.filter(
|
||||
(x) => x.admin === user.id || x.participants.includes(user.id)
|
||||
)}
|
||||
users={users.filter(
|
||||
(x) =>
|
||||
x.type === "student" &&
|
||||
(!!selectedUser
|
||||
? groups
|
||||
.filter((g) => g.admin === selectedUser.id)
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id) || false
|
||||
: groups.flatMap((g) => g.participants).includes(x.id))
|
||||
)}
|
||||
assigner={user.id}
|
||||
isCreating={isCreatingAssignment}
|
||||
cancelCreation={() => {
|
||||
setIsCreatingAssignment(false);
|
||||
setSelectedAssignment(undefined);
|
||||
reloadAssignments();
|
||||
}}
|
||||
/>
|
||||
<div className="w-full flex justify-between items-center">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||
>
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<div
|
||||
onClick={reloadAssignments}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||
>
|
||||
<span>Reload</span>
|
||||
<BsArrowRepeat
|
||||
className={clsx(
|
||||
"text-xl",
|
||||
isAssignmentsLoading && "animate-spin"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-2xl font-semibold">
|
||||
Active Assignments ({assignments.filter(activeFilter).length})
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{assignments.filter(activeFilter).map((a) => (
|
||||
<AssignmentCard
|
||||
{...a}
|
||||
onClick={() => setSelectedAssignment(a)}
|
||||
key={a.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-2xl font-semibold">
|
||||
Planned Assignments ({assignments.filter(futureFilter).length})
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div
|
||||
onClick={() => setIsCreatingAssignment(true)}
|
||||
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300"
|
||||
>
|
||||
<BsPlus className="text-6xl" />
|
||||
<span className="text-lg">New Assignment</span>
|
||||
</div>
|
||||
{assignments.filter(futureFilter).map((a) => (
|
||||
<AssignmentCard
|
||||
{...a}
|
||||
onClick={() => {
|
||||
setSelectedAssignment(a);
|
||||
setIsCreatingAssignment(true);
|
||||
}}
|
||||
key={a.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-2xl font-semibold">
|
||||
Past Assignments ({assignments.filter(pastFilter).length})
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{assignments.filter(pastFilter).map((a) => (
|
||||
<AssignmentCard
|
||||
{...a}
|
||||
onClick={() => setSelectedAssignment(a)}
|
||||
key={a.id}
|
||||
allowDownload
|
||||
reload={reloadAssignments}
|
||||
allowArchive
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
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();
|
||||
}}
|
||||
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),
|
||||
});
|
||||
|
||||
const DefaultDashboard = () => (
|
||||
<>
|
||||
{corporateUserToShow && (
|
||||
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
|
||||
Linked to:{" "}
|
||||
<b>
|
||||
{corporateUserToShow?.corporateInformation?.companyInformation
|
||||
.name || corporateUserToShow.name}
|
||||
</b>
|
||||
</div>
|
||||
)}
|
||||
<section
|
||||
className={clsx(
|
||||
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center",
|
||||
!!corporateUserToShow && "mt-12 xl:mt-6"
|
||||
)}
|
||||
>
|
||||
<IconCard
|
||||
onClick={() => setPage("students")}
|
||||
Icon={BsPersonFill}
|
||||
label="Students"
|
||||
value={users.filter(studentFilter).length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsClipboard2Data}
|
||||
label="Exams Performed"
|
||||
value={
|
||||
stats.filter((s) =>
|
||||
groups.flatMap((g) => g.participants).includes(s.user)
|
||||
).length
|
||||
}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPaperclip}
|
||||
label="Average Level"
|
||||
value={averageLevelCalculator(
|
||||
stats.filter((s) =>
|
||||
groups.flatMap((g) => g.participants).includes(s.user)
|
||||
)
|
||||
).toFixed(1)}
|
||||
color="purple"
|
||||
/>
|
||||
<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>
|
||||
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),
|
||||
});
|
||||
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
router.push("/list/users");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
user={selectedUser}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Modal>
|
||||
|
||||
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"
|
||||
? () => setPage("students")
|
||||
: undefined
|
||||
}
|
||||
onViewTeachers={
|
||||
selectedUser.type === "corporate"
|
||||
? () => setPage("teachers")
|
||||
: undefined
|
||||
}
|
||||
user={selectedUser}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Modal>
|
||||
{page === "students" && <StudentsList />}
|
||||
{page === "groups" && <GroupsList />}
|
||||
{page === "assignments" && <AssignmentsPage />}
|
||||
{page === "" && <DefaultDashboard />}
|
||||
</>
|
||||
);
|
||||
<>
|
||||
{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={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 {User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {calculateBandScore} from "@/utils/score";
|
||||
import {calculateBandScore, getGradingLabel} from "@/utils/score";
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
import {useRouter} from "next/router";
|
||||
import {Fragment, useEffect, useState} from "react";
|
||||
import {
|
||||
BsArrowCounterclockwise,
|
||||
BsBan,
|
||||
BsBook,
|
||||
BsClipboard,
|
||||
BsClipboardFill,
|
||||
@@ -24,8 +25,10 @@ import {LevelScore} from "@/constants/ielts";
|
||||
import {getLevelScore} from "@/utils/score";
|
||||
import {capitalize} from "lodash";
|
||||
import Modal from "@/components/Modal";
|
||||
import { UserSolution } from "@/interfaces/exam";
|
||||
import {UserSolution} from "@/interfaces/exam";
|
||||
import ai_usage from "@/utils/ai.detection";
|
||||
import useGradingSystem from "@/hooks/useGrading";
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
|
||||
interface Score {
|
||||
module: Module;
|
||||
@@ -44,17 +47,18 @@ interface Props {
|
||||
};
|
||||
solutions: UserSolution[];
|
||||
isLoading: boolean;
|
||||
assignment?: Assignment;
|
||||
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 [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
|
||||
const [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false);
|
||||
|
||||
const aiUsage = Math.round(ai_usage(solutions) * 100);
|
||||
|
||||
const exams = useExamStore((state) => state.exams);
|
||||
const {gradingSystem} = useGradingSystem();
|
||||
|
||||
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) => {
|
||||
if (selectedModule === "level") {
|
||||
const [levelStr, grade] = getLevelScore(level);
|
||||
const label = getGradingLabel(level, gradingSystem?.steps || []);
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -155,26 +159,24 @@ export default function Finish({user, scores, modules, information, solutions, i
|
||||
)}
|
||||
{modules.includes("writing") && (
|
||||
<div className="flex w-full justify-between items-center">
|
||||
<div
|
||||
onClick={() => setSelectedModule("writing")}
|
||||
className={clsx(
|
||||
"hover:bg-ielts-writing flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
|
||||
selectedModule === "writing" ? "bg-ielts-writing text-white" : "bg-mti-gray-smoke text-ielts-writing",
|
||||
)}>
|
||||
<BsPen className="h-6 w-6" />
|
||||
<span className="font-semibold">Writing</span>
|
||||
</div>
|
||||
{aiUsage >= 50 && user.type !== "student" && (
|
||||
<div className={clsx(
|
||||
"flex items-center justify-center border px-3 h-full rounded",
|
||||
{
|
||||
'bg-orange-100 border-orange-400 text-orange-700': aiUsage < 80,
|
||||
'bg-red-100 border-red-400 text-red-700': aiUsage >= 80,
|
||||
}
|
||||
)}>
|
||||
<span className="text-xs">AI Usage</span>
|
||||
<div
|
||||
onClick={() => setSelectedModule("writing")}
|
||||
className={clsx(
|
||||
"hover:bg-ielts-writing flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg",
|
||||
selectedModule === "writing" ? "bg-ielts-writing text-white" : "bg-mti-gray-smoke text-ielts-writing",
|
||||
)}>
|
||||
<BsPen className="h-6 w-6" />
|
||||
<span className="font-semibold">Writing</span>
|
||||
</div>
|
||||
)}
|
||||
{aiUsage >= 50 && user.type !== "student" && (
|
||||
<div
|
||||
className={clsx("flex items-center justify-center border px-3 h-full rounded", {
|
||||
"bg-orange-100 border-orange-400 text-orange-700": aiUsage < 80,
|
||||
"bg-red-100 border-red-400 text-red-700": aiUsage >= 80,
|
||||
})}>
|
||||
<span className="text-xs">AI Usage</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{modules.includes("speaking") && (
|
||||
@@ -210,7 +212,18 @@ export default function Finish({user, scores, modules, information, solutions, i
|
||||
</span>
|
||||
</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">
|
||||
<span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span>
|
||||
<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">
|
||||
<button
|
||||
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">
|
||||
<BsArrowCounterclockwise className="h-7 w-7 text-white" />
|
||||
</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">
|
||||
<button
|
||||
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">
|
||||
<BsEyeFill className="h-7 w-7 text-white" />
|
||||
</button>
|
||||
@@ -290,6 +306,7 @@ export default function Finish({user, scores, modules, information, solutions, i
|
||||
</div>
|
||||
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||
<button
|
||||
disabled={assignment && !assignment.released}
|
||||
onClick={() => onViewResults(modules.findIndex((x) => x === selectedModule))}
|
||||
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||
<BsEyeFill className="h-7 w-7 text-white" />
|
||||
|
||||
@@ -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 AudioPlayer from "@/components/Low/AudioPlayer";
|
||||
import Button from "@/components/Low/Button";
|
||||
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
||||
import BlankQuestionsModal from "@/components/QuestionsModal";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {countExercises} from "@/utils/moduleUtils";
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import {Divider} from "primereact/divider";
|
||||
import Button from "@/components/Low/Button";
|
||||
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
||||
import BlankQuestionsModal from "@/components/QuestionsModal";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {defaultUserSolutions} from "@/utils/exams";
|
||||
import {countExercises} from "@/utils/moduleUtils";
|
||||
|
||||
@@ -1,442 +1,311 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { useState } from "react";
|
||||
import { Module } from "@/interfaces";
|
||||
import {useState} from "react";
|
||||
import {Module} from "@/interfaces";
|
||||
import clsx from "clsx";
|
||||
import { User } from "@/interfaces/user";
|
||||
import {Stat, User} from "@/interfaces/user";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import {
|
||||
BsArrowRepeat,
|
||||
BsBook,
|
||||
BsCheck,
|
||||
BsCheckCircle,
|
||||
BsClipboard,
|
||||
BsHeadphones,
|
||||
BsMegaphone,
|
||||
BsPen,
|
||||
BsXCircle,
|
||||
} from "react-icons/bs";
|
||||
import { totalExamsByModule } from "@/utils/stats";
|
||||
import useStats from "@/hooks/useStats";
|
||||
import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
||||
import {totalExamsByModule} from "@/utils/stats";
|
||||
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||
import Button from "@/components/Low/Button";
|
||||
import { calculateAverageLevel } from "@/utils/score";
|
||||
import { sortByModuleName } from "@/utils/moduleUtils";
|
||||
import { capitalize } from "lodash";
|
||||
import {calculateAverageLevel} from "@/utils/score";
|
||||
import {sortByModuleName} from "@/utils/moduleUtils";
|
||||
import {capitalize} from "lodash";
|
||||
import ProfileSummary from "@/components/ProfileSummary";
|
||||
import { Variant } from "@/interfaces/exam";
|
||||
import useSessions, { Session } from "@/hooks/useSessions";
|
||||
import {ShuffleMap, Shuffles, Variant} from "@/interfaces/exam";
|
||||
import useSessions, {Session} from "@/hooks/useSessions";
|
||||
import SessionCard from "@/components/Medium/SessionCard";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import moment from "moment";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
page: "exercises" | "exams";
|
||||
onStart: (
|
||||
modules: Module[],
|
||||
avoidRepeated: boolean,
|
||||
variant: Variant,
|
||||
) => void;
|
||||
disableSelection?: boolean;
|
||||
user: User;
|
||||
page: "exercises" | "exams";
|
||||
onStart: (modules: Module[], avoidRepeated: boolean, variant: Variant) => void;
|
||||
disableSelection?: boolean;
|
||||
}
|
||||
|
||||
export default function Selection({
|
||||
user,
|
||||
page,
|
||||
onStart,
|
||||
disableSelection = false,
|
||||
}: Props) {
|
||||
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
||||
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
|
||||
const [variant, setVariant] = useState<Variant>("full");
|
||||
export default function Selection({user, page, onStart, disableSelection = false}: Props) {
|
||||
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
||||
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
|
||||
const [variant, setVariant] = useState<Variant>("full");
|
||||
|
||||
const { stats } = useStats(user?.id);
|
||||
const { sessions, isLoading, reload } = useSessions(user.id);
|
||||
const {data: stats} = useFilterRecordsByUser<Stat[]>(user?.id);
|
||||
const {sessions, isLoading, reload} = useSessions(user.id);
|
||||
|
||||
const state = useExamStore((state) => state);
|
||||
const state = useExamStore((state) => state);
|
||||
|
||||
const toggleModule = (module: Module) => {
|
||||
const modules = selectedModules.filter((x) => x !== module);
|
||||
setSelectedModules((prev) =>
|
||||
prev.includes(module) ? modules : [...modules, module],
|
||||
);
|
||||
};
|
||||
const toggleModule = (module: Module) => {
|
||||
const modules = selectedModules.filter((x) => x !== module);
|
||||
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
|
||||
};
|
||||
|
||||
const loadSession = async (session: Session) => {
|
||||
state.setSelectedModules(session.selectedModules);
|
||||
state.setExam(session.exam);
|
||||
state.setExams(session.exams);
|
||||
state.setSessionId(session.sessionId);
|
||||
state.setAssignment(session.assignment);
|
||||
state.setExerciseIndex(session.exerciseIndex);
|
||||
state.setPartIndex(session.partIndex);
|
||||
state.setModuleIndex(session.moduleIndex);
|
||||
state.setTimeSpent(session.timeSpent);
|
||||
state.setUserSolutions(session.userSolutions);
|
||||
state.setShowSolutions(false);
|
||||
state.setQuestionIndex(session.questionIndex);
|
||||
};
|
||||
const loadSession = async (session: Session) => {
|
||||
state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []})));
|
||||
state.setSelectedModules(session.selectedModules);
|
||||
state.setExam(session.exam);
|
||||
state.setExams(session.exams);
|
||||
state.setSessionId(session.sessionId);
|
||||
state.setAssignment(session.assignment);
|
||||
state.setExerciseIndex(session.exerciseIndex);
|
||||
state.setPartIndex(session.partIndex);
|
||||
state.setModuleIndex(session.moduleIndex);
|
||||
state.setTimeSpent(session.timeSpent);
|
||||
state.setUserSolutions(session.userSolutions);
|
||||
state.setShowSolutions(false);
|
||||
state.setQuestionIndex(session.questionIndex);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex h-full w-full flex-col gap-8 md:gap-16">
|
||||
{user && (
|
||||
<ProfileSummary
|
||||
user={user}
|
||||
items={[
|
||||
{
|
||||
icon: (
|
||||
<BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Reading",
|
||||
value: totalExamsByModule(stats, "reading"),
|
||||
tooltip: "The amount of reading exams performed.",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Listening",
|
||||
value: totalExamsByModule(stats, "listening"),
|
||||
tooltip: "The amount of listening exams performed.",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Writing",
|
||||
value: totalExamsByModule(stats, "writing"),
|
||||
tooltip: "The amount of writing exams performed.",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Speaking",
|
||||
value: totalExamsByModule(stats, "speaking"),
|
||||
tooltip: "The amount of speaking exams performed.",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />
|
||||
),
|
||||
label: "Level",
|
||||
value: totalExamsByModule(stats, "level"),
|
||||
tooltip: "The amount of level exams performed.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex h-full w-full flex-col gap-8 md:gap-16">
|
||||
{user && (
|
||||
<ProfileSummary
|
||||
user={user}
|
||||
items={[
|
||||
{
|
||||
icon: <BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />,
|
||||
label: "Reading",
|
||||
value: totalExamsByModule(stats, "reading"),
|
||||
tooltip: "The amount of reading exams performed.",
|
||||
},
|
||||
{
|
||||
icon: <BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />,
|
||||
label: "Listening",
|
||||
value: totalExamsByModule(stats, "listening"),
|
||||
tooltip: "The amount of listening exams performed.",
|
||||
},
|
||||
{
|
||||
icon: <BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />,
|
||||
label: "Writing",
|
||||
value: totalExamsByModule(stats, "writing"),
|
||||
tooltip: "The amount of writing exams performed.",
|
||||
},
|
||||
{
|
||||
icon: <BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />,
|
||||
label: "Speaking",
|
||||
value: totalExamsByModule(stats, "speaking"),
|
||||
tooltip: "The amount of speaking exams performed.",
|
||||
},
|
||||
{
|
||||
icon: <BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />,
|
||||
label: "Level",
|
||||
value: totalExamsByModule(stats, "level"),
|
||||
tooltip: "The amount of level exams performed.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<span className="text-lg font-bold">About {capitalize(page)}</span>
|
||||
<span className="text-mti-gray-taupe">
|
||||
{page === "exercises" && (
|
||||
<>
|
||||
In the realm of language acquisition, practice makes perfect,
|
||||
and our exercises are the key to unlocking your full potential.
|
||||
Dive into a world of interactive and engaging exercises that
|
||||
cater to diverse learning styles. From grammar drills that build
|
||||
a strong foundation to vocabulary challenges that broaden your
|
||||
lexicon, our exercises are carefully designed to make learning
|
||||
English both enjoyable and effective. Whether you're
|
||||
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" && (
|
||||
<>
|
||||
Welcome to the heart of success on your English language
|
||||
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 your
|
||||
abilities. Whether you're a beginner or a seasoned learner,
|
||||
our exams cater to all levels, providing a comprehensive
|
||||
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>
|
||||
</section>
|
||||
<section className="flex flex-col gap-3">
|
||||
<span className="text-lg font-bold">About {capitalize(page)}</span>
|
||||
<span className="text-mti-gray-taupe">
|
||||
{page === "exercises" && (
|
||||
<>
|
||||
In the realm of language acquisition, practice makes perfect, and our exercises are the key to unlocking your full
|
||||
potential. Dive into a world of interactive and engaging exercises that cater to diverse learning styles. From grammar
|
||||
drills that build a strong foundation to vocabulary challenges that broaden your lexicon, our exercises are carefully
|
||||
designed to make learning English both enjoyable and effective. Whether you're 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" && (
|
||||
<>
|
||||
Welcome to the heart of success on your English language 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
|
||||
your abilities. Whether you're a beginner or a seasoned learner, our exams cater to all levels, providing a
|
||||
comprehensive 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>
|
||||
</section>
|
||||
|
||||
{sessions.length > 0 && (
|
||||
<section className="flex flex-col gap-3 md:gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<span className="text-mti-black text-lg font-bold">
|
||||
Unfinished Sessions
|
||||
</span>
|
||||
<BsArrowRepeat
|
||||
className={clsx("text-xl", isLoading && "animate-spin")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||
{sessions
|
||||
.sort((a, b) => moment(b.date).diff(moment(a.date)))
|
||||
.map((session) => (
|
||||
<SessionCard
|
||||
session={session}
|
||||
key={session.sessionId}
|
||||
reload={reload}
|
||||
loadSession={loadSession}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</section>
|
||||
)}
|
||||
{sessions.length > 0 && (
|
||||
<section className="flex flex-col gap-3 md:gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
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">
|
||||
<span className="text-mti-black text-lg font-bold">Unfinished Sessions</span>
|
||||
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-mti-gray-taupe flex gap-8 overflow-x-auto pb-2">
|
||||
{sessions
|
||||
.sort((a, b) => moment(b.date).diff(moment(a.date)))
|
||||
.map((session) => (
|
||||
<SessionCard session={session} key={session.sessionId} reload={reload} loadSession={loadSession} />
|
||||
))}
|
||||
</span>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
|
||||
<div
|
||||
onClick={
|
||||
!disableSelection && !selectedModules.includes("level")
|
||||
? () => toggleModule("reading")
|
||||
: undefined
|
||||
}
|
||||
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",
|
||||
selectedModules.includes("reading") || disableSelection
|
||||
? "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">
|
||||
<BsBook className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Reading:</span>
|
||||
<p className="text-left text-xs">
|
||||
Expand your vocabulary, improve your reading comprehension and
|
||||
improve your ability to interpret texts in English.
|
||||
</p>
|
||||
{!selectedModules.includes("reading") &&
|
||||
!selectedModules.includes("level") &&
|
||||
!disableSelection && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("reading") || disableSelection) && (
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && (
|
||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onClick={
|
||||
!disableSelection && !selectedModules.includes("level")
|
||||
? () => toggleModule("listening")
|
||||
: undefined
|
||||
}
|
||||
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",
|
||||
selectedModules.includes("listening") || disableSelection
|
||||
? "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">
|
||||
<BsHeadphones className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Listening:</span>
|
||||
<p className="text-left text-xs">
|
||||
Improve your ability to follow conversations in English and your
|
||||
ability to understand different accents and intonations.
|
||||
</p>
|
||||
{!selectedModules.includes("listening") &&
|
||||
!selectedModules.includes("level") &&
|
||||
!disableSelection && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("listening") || disableSelection) && (
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && (
|
||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onClick={
|
||||
!disableSelection && !selectedModules.includes("level")
|
||||
? () => toggleModule("writing")
|
||||
: undefined
|
||||
}
|
||||
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",
|
||||
selectedModules.includes("writing") || disableSelection
|
||||
? "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">
|
||||
<BsPen className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Writing:</span>
|
||||
<p className="text-left text-xs">
|
||||
Allow you to practice writing in a variety of formats, from simple
|
||||
paragraphs to complex essays.
|
||||
</p>
|
||||
{!selectedModules.includes("writing") &&
|
||||
!selectedModules.includes("level") &&
|
||||
!disableSelection && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("writing") || disableSelection) && (
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && (
|
||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onClick={
|
||||
!disableSelection && !selectedModules.includes("level")
|
||||
? () => toggleModule("speaking")
|
||||
: undefined
|
||||
}
|
||||
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",
|
||||
selectedModules.includes("speaking") || disableSelection
|
||||
? "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">
|
||||
<BsMegaphone className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Speaking:</span>
|
||||
<p className="text-left text-xs">
|
||||
You'll have access to interactive dialogs, pronunciation
|
||||
exercises and speech recordings.
|
||||
</p>
|
||||
{!selectedModules.includes("speaking") &&
|
||||
!selectedModules.includes("level") &&
|
||||
!disableSelection && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("speaking") || disableSelection) && (
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && (
|
||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
</div>
|
||||
{!disableSelection && (
|
||||
<div
|
||||
onClick={
|
||||
selectedModules.length === 0 ||
|
||||
selectedModules.includes("level")
|
||||
? () => toggleModule("level")
|
||||
: undefined
|
||||
}
|
||||
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",
|
||||
selectedModules.includes("level") || disableSelection
|
||||
? "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">
|
||||
<BsClipboard className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Level:</span>
|
||||
<p className="text-left text-xs">
|
||||
You'll be able to test your english level with multiple
|
||||
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" />
|
||||
)}
|
||||
{(selectedModules.includes("level") || disableSelection) && (
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{!selectedModules.includes("level") &&
|
||||
selectedModules.length > 0 && (
|
||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<div className="-md:flex-col -md:gap-4 -md:justify-center flex w-full items-center md:justify-between">
|
||||
<div className="flex w-full flex-col items-center gap-3">
|
||||
<div
|
||||
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
|
||||
onClick={() => setAvoidRepeatedExams((prev) => !prev)}
|
||||
>
|
||||
<input type="checkbox" className="hidden" />
|
||||
<div
|
||||
className={clsx(
|
||||
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
avoidRepeatedExams && "!bg-mti-purple-light ",
|
||||
)}
|
||||
>
|
||||
<BsCheck color="white" className="h-full w-full" />
|
||||
</div>
|
||||
<span
|
||||
className="tooltip"
|
||||
data-tip="If possible, the platform will choose exams not yet done."
|
||||
>
|
||||
Avoid Repeated Questions
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
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"))}>
|
||||
>
|
||||
<input type="checkbox" className="hidden" disabled />
|
||||
<div
|
||||
className={clsx(
|
||||
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
variant === "full" && "!bg-mti-purple-light ",
|
||||
)}
|
||||
>
|
||||
<BsCheck color="white" className="h-full w-full" />
|
||||
</div>
|
||||
<span>Full length exams</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="tooltip w-full"
|
||||
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
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onStart(
|
||||
!disableSelection
|
||||
? selectedModules.sort(sortByModuleName)
|
||||
: ["reading", "listening", "writing", "speaking"],
|
||||
avoidRepeatedExams,
|
||||
variant,
|
||||
)
|
||||
}
|
||||
color="purple"
|
||||
className="-md:hidden w-full max-w-xs px-12 md:self-end"
|
||||
disabled={selectedModules.length === 0 && !disableSelection}
|
||||
>
|
||||
Start Exam
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
|
||||
<div
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
|
||||
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",
|
||||
selectedModules.includes("reading") || disableSelection ? "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">
|
||||
<BsBook className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Reading:</span>
|
||||
<p className="text-left text-xs">
|
||||
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
|
||||
</p>
|
||||
{!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("reading") || disableSelection) && (
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
|
||||
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",
|
||||
selectedModules.includes("listening") || disableSelection ? "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">
|
||||
<BsHeadphones className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Listening:</span>
|
||||
<p className="text-left text-xs">
|
||||
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
|
||||
</p>
|
||||
{!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("listening") || disableSelection) && (
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
|
||||
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",
|
||||
selectedModules.includes("writing") || disableSelection ? "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">
|
||||
<BsPen className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Writing:</span>
|
||||
<p className="text-left text-xs">
|
||||
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
|
||||
</p>
|
||||
{!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("writing") || disableSelection) && (
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
||||
</div>
|
||||
<div
|
||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
|
||||
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",
|
||||
selectedModules.includes("speaking") || disableSelection ? "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">
|
||||
<BsMegaphone className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Speaking:</span>
|
||||
<p className="text-left text-xs">
|
||||
You'll have access to interactive dialogs, pronunciation exercises and speech recordings.
|
||||
</p>
|
||||
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && (
|
||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||
)}
|
||||
{(selectedModules.includes("speaking") || disableSelection) && (
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
||||
</div>
|
||||
{!disableSelection && (
|
||||
<div
|
||||
onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
|
||||
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",
|
||||
selectedModules.includes("level") || disableSelection ? "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">
|
||||
<BsClipboard className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold">Level:</span>
|
||||
<p className="text-left text-xs">You'll be able to test your english level with multiple 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" />
|
||||
)}
|
||||
{(selectedModules.includes("level") || disableSelection) && (
|
||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
{!selectedModules.includes("level") && selectedModules.length > 0 && (
|
||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<div className="-md:flex-col -md:gap-4 -md:justify-center flex w-full items-center md:justify-between">
|
||||
<div className="flex w-full flex-col items-center gap-3">
|
||||
<div
|
||||
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
|
||||
onClick={() => setAvoidRepeatedExams((prev) => !prev)}>
|
||||
<input type="checkbox" className="hidden" />
|
||||
<div
|
||||
className={clsx(
|
||||
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
avoidRepeatedExams && "!bg-mti-purple-light ",
|
||||
)}>
|
||||
<BsCheck color="white" className="h-full w-full" />
|
||||
</div>
|
||||
<span className="tooltip" data-tip="If possible, the platform will choose exams not yet done.">
|
||||
Avoid Repeated Questions
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
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"))}>
|
||||
<input type="checkbox" className="hidden" />
|
||||
<div
|
||||
className={clsx(
|
||||
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
variant === "full" && "!bg-mti-purple-light ",
|
||||
)}>
|
||||
<BsCheck color="white" className="h-full w-full" />
|
||||
</div>
|
||||
<span>Full length exams</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="tooltip w-full" 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
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onStart(
|
||||
!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"],
|
||||
avoidRepeatedExams,
|
||||
variant,
|
||||
)
|
||||
}
|
||||
color="purple"
|
||||
className="-md:hidden w-full max-w-xs px-12 md:self-end"
|
||||
disabled={selectedModules.length === 0 && !disableSelection}>
|
||||
Start Exam
|
||||
</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 * as admin from "firebase-admin/app";
|
||||
import {getStorage} from "firebase/storage";
|
||||
import { base64 } from "@firebase/util";
|
||||
|
||||
const stagingServiceAccount = require("@/constants/staging.json");
|
||||
const platformServiceAccount = require("@/constants/platform.json");
|
||||
@@ -22,3 +23,10 @@ export const adminApp = admin.initializeApp(
|
||||
Math.random().toString(),
|
||||
);
|
||||
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 axios from "axios";
|
||||
import Axios from "axios";
|
||||
import {setupCache} from "axios-cache-interceptor";
|
||||
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 [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
@@ -10,12 +14,13 @@ export default function useAssignments({assigner, assignees}: {assigner?: string
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.get<Assignment[]>("/api/assignments")
|
||||
.then((response) => {
|
||||
.get<Assignment[]>(!corporate ? "/api/assignments" : `/api/assignments/corporate/${corporate}`)
|
||||
.then(async (response) => {
|
||||
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;
|
||||
}
|
||||
|
||||
if (assignees) {
|
||||
setAssignments(response.data.filter((a) => a.assignees.filter((x) => assignees.includes(x)).length > 0));
|
||||
return;
|
||||
@@ -26,7 +31,7 @@ export default function useAssignments({assigner, assignees}: {assigner?: string
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(getData, [assignees, assigner]);
|
||||
useEffect(getData, [assignees, assigner, corporate]);
|
||||
|
||||
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 axios from "axios";
|
||||
import Axios from "axios";
|
||||
import {setupCache} from "axios-cache-interceptor";
|
||||
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 [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const isMasterType = userType?.startsWith('master');
|
||||
const isMasterType = userType?.startsWith("master");
|
||||
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const url = admin ? `/api/groups?admin=${admin}` : "/api/groups";
|
||||
const url = admin && !adminAdmins ? `/api/groups?admin=${admin}` : "/api/groups";
|
||||
axios
|
||||
.get<Group[]>(url)
|
||||
.then((response) => {
|
||||
if(isMasterType) {
|
||||
return setGroups(response.data);
|
||||
}
|
||||
const filter = (g: Group) => g.admin === admin || g.participants.includes(admin || "");
|
||||
if (isMasterType) return setGroups(response.data);
|
||||
|
||||
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);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(getData, [admin, isMasterType]);
|
||||
useEffect(getData, [admin, adminAdmins, isMasterType]);
|
||||
|
||||
return {groups, isLoading, isError, reload: getData};
|
||||
}
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
import {useState, useMemo} from "react";
|
||||
import Input from "@/components/Low/Input";
|
||||
|
||||
/*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;
|
||||
};
|
||||
import {search} from "@/utils/search";
|
||||
|
||||
export function useListSearch<T>(fields: string[][], rows: T[]) {
|
||||
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 updatedRows = useMemo(() => {
|
||||
const searchText = text.toLowerCase();
|
||||
return rows.filter((row) => {
|
||||
return fields.some((fieldsKeys) => {
|
||||
const value = getFieldValue(fieldsKeys, row);
|
||||
if (typeof value === "string") {
|
||||
return value.toLowerCase().includes(searchText);
|
||||
}
|
||||
});
|
||||
});
|
||||
if (text.length > 0) return search(text, fields, rows);
|
||||
return rows;
|
||||
}, [fields, rows, text]);
|
||||
|
||||
return {
|
||||
text,
|
||||
rows: updatedRows,
|
||||
renderSearch,
|
||||
};
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import React from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
import { BsFilePdf } from "react-icons/bs";
|
||||
import { BsFilePdf, BsFileExcel} from "react-icons/bs";
|
||||
|
||||
type DownloadingPdf = {
|
||||
[key: string]: boolean;
|
||||
};
|
||||
|
||||
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>(
|
||||
{}
|
||||
);
|
||||
@@ -17,7 +18,7 @@ export const usePDFDownload = (endpoint: PdfEndpoint) => {
|
||||
const triggerDownload = async (id: string) => {
|
||||
try {
|
||||
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!");
|
||||
const link = document.createElement("a");
|
||||
link.href = res.data;
|
||||
@@ -45,8 +46,11 @@ export const usePDFDownload = (endpoint: PdfEndpoint) => {
|
||||
<span className={`${loadingClasses} loading loading-infinity w-6`} />
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = file === "excel" ? BsFileExcel : BsFilePdf;
|
||||
|
||||
return (
|
||||
<BsFilePdf
|
||||
<Icon
|
||||
className={`${downloadClasses} text-2xl cursor-pointer`}
|
||||
onClick={(e) => {
|
||||
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 {ExamState} from "@/stores/examStore";
|
||||
import axios from "axios";
|
||||
import {setupCache} from "axios-cache-interceptor";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
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