Compare commits
201 Commits
feature-pa
...
feature/up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b37b60be0 | ||
|
|
4347d0cabb | ||
|
|
0403773b8e | ||
|
|
8d99a6b03c | ||
|
|
02320b9484 | ||
|
|
fb077fd8cc | ||
|
|
b5a305485f | ||
|
|
8f5b27e9ce | ||
|
|
9ef04b822a | ||
|
|
a6160c3cf0 | ||
|
|
8f6639b7fc | ||
|
|
6a803fe137 | ||
|
|
d7f6a4dde7 | ||
|
|
6058e510de | ||
|
|
7208530879 | ||
|
|
9b6c545932 | ||
|
|
afb9071758 | ||
|
|
d50393930e | ||
|
|
03e1f2cfa3 | ||
|
|
877d2f359f | ||
|
|
45df9837e7 | ||
|
|
923319051c | ||
|
|
f6b4d6ad52 | ||
|
|
19d16c9cef | ||
|
|
daa27e41b3 | ||
|
|
916fa66446 | ||
|
|
10a3243756 | ||
|
|
a1c7f70329 | ||
|
|
bd2efb0ef5 | ||
|
|
34065f1f6e | ||
|
|
41873f80d7 | ||
|
|
a1b67c017d | ||
|
|
13fd7e1ee5 | ||
|
|
4996417218 | ||
|
|
60d436b5b9 | ||
|
|
8d39a20267 | ||
|
|
5d46d7e453 | ||
|
|
15f9fb320d | ||
|
|
494fc9bab6 | ||
|
|
0c5c024098 | ||
|
|
903a567805 | ||
|
|
df3929d5e6 | ||
|
|
6d62500596 | ||
|
|
e5e4e87752 | ||
|
|
0b3e686f3f | ||
|
|
3da87cce60 | ||
|
|
c9daba17e1 | ||
|
|
5cfd6d56a6 | ||
|
|
ec8c06ca94 | ||
|
|
77a22b3ab3 | ||
|
|
e79139174b | ||
|
|
61a86394ed | ||
|
|
f6741dd80e | ||
|
|
ce6708be6e | ||
|
|
b62cae2e3a | ||
|
|
d73b6d9d12 | ||
|
|
c11906a395 | ||
|
|
a29b0b56d9 | ||
|
|
53dbf99fba | ||
|
|
cb49e15cb0 | ||
|
|
0eddded560 | ||
|
|
11c6f70576 | ||
|
|
6712e89c47 | ||
|
|
9959cf4294 | ||
|
|
daec246835 | ||
|
|
8ea97ee944 | ||
|
|
975f4c8285 | ||
|
|
f0b85409c9 | ||
|
|
bdd862c633 | ||
|
|
4166781f7e | ||
|
|
1f8e9106de | ||
|
|
9e651358d5 | ||
|
|
5aed336c96 | ||
|
|
85b94512e9 | ||
|
|
906646ebce | ||
|
|
96108a4958 | ||
|
|
fb449f2054 | ||
|
|
d5ee3d9519 | ||
|
|
4e20ec6575 | ||
|
|
836b674076 | ||
|
|
5086c6fb09 | ||
|
|
489c9c3b7e | ||
|
|
e3ded29e77 | ||
|
|
16419a5584 | ||
|
|
3e3b24cc30 | ||
|
|
841698ba10 | ||
|
|
d50904611c | ||
|
|
e77fd16d26 | ||
|
|
649f24e4ae | ||
|
|
2f0cbfe74e | ||
|
|
d022bd078a | ||
|
|
c18afee9ad | ||
|
|
a65b72adad | ||
|
|
e13aea9f7d | ||
|
|
2920fa7f3a | ||
|
|
7af96ecccc | ||
|
|
70716b3483 | ||
|
|
d7bb64e7e0 | ||
|
|
dd19b5746c | ||
|
|
f967282f71 | ||
|
|
8b2459c304 | ||
|
|
72fb934d4f | ||
|
|
ed0b8bcb99 | ||
|
|
6f211d8435 | ||
|
|
b59589b855 | ||
|
|
db20feaa00 | ||
|
|
8fc2cf571e | ||
|
|
3128fea8c9 | ||
|
|
0e53b4a454 | ||
|
|
cbb61d18fe | ||
|
|
dff51cf6ea | ||
|
|
15dbadcc53 | ||
|
|
624a3fb88e | ||
|
|
00feee2179 | ||
|
|
0f8f9bc05b | ||
|
|
f76b7578a6 | ||
|
|
1a17689cd2 | ||
|
|
a958e2ff0d | ||
|
|
36b861266f | ||
|
|
771262fc18 | ||
|
|
0f03ce95e7 | ||
|
|
6a6e010daa | ||
|
|
13496387c4 | ||
|
|
4ecb21e0ae | ||
|
|
8663fe13bd | ||
|
|
de4638bc46 | ||
|
|
c9740fe8ee | ||
|
|
9b9b67c6cd | ||
|
|
fe2abaacae | ||
|
|
11e2ea3249 | ||
|
|
2de4b7c715 | ||
|
|
a8ffebe944 | ||
|
|
9ab7c3ed59 | ||
|
|
f374d91ef8 | ||
|
|
62ecc4e395 | ||
|
|
46764cacfa | ||
|
|
0b9e1bd734 | ||
|
|
bddb2ed18e | ||
|
|
e8fbeff77a | ||
|
|
b64593df90 | ||
|
|
2657cb409c | ||
|
|
329ed573b3 | ||
|
|
bb7558afb8 | ||
|
|
259ed03ee4 | ||
|
|
bf6c805487 | ||
|
|
1086e78936 | ||
|
|
7d0d930140 | ||
|
|
f02fff55e7 | ||
|
|
08e71c4dd8 | ||
|
|
6f5a74844c | ||
|
|
c4011cd456 | ||
|
|
5ef2568aa5 | ||
|
|
6d817e6d27 | ||
|
|
5decfb098d | ||
|
|
c2b6be4425 | ||
|
|
f320fee416 | ||
|
|
445e486cd2 | ||
|
|
ee26b50cf6 | ||
|
|
22f2b43692 | ||
|
|
29b2c8b3b8 | ||
|
|
51cc1e3f36 | ||
|
|
d9fce10538 | ||
|
|
bd74313bd5 | ||
|
|
18df890ef9 | ||
|
|
13ebb9bbd8 | ||
|
|
38c0c823e1 | ||
|
|
b50e15d1d9 | ||
|
|
969698d8b8 | ||
|
|
7d83ebc5c5 | ||
|
|
e99650ecd8 | ||
|
|
7287a9ce9a | ||
|
|
8cc7e6a57d | ||
|
|
0a24cb9978 | ||
|
|
a5c1286748 | ||
|
|
06684a4900 | ||
|
|
1823538058 | ||
|
|
60ccc822b5 | ||
|
|
9abd69c5e5 | ||
|
|
2667891bdd | ||
|
|
65485a0d1f | ||
|
|
74dd96d000 | ||
|
|
49ee3c45e5 | ||
|
|
49d2680a07 | ||
|
|
9dac7fd19e | ||
|
|
528299571c | ||
|
|
dcc630b8e5 | ||
|
|
be5125e5b0 | ||
|
|
0adf45c6ad | ||
|
|
d9b93a3470 | ||
|
|
83e4173750 | ||
|
|
e2d5f6ac9d | ||
|
|
37c3c6f7f4 | ||
|
|
3b4dfb9648 | ||
|
|
330c177ff9 | ||
|
|
0cff310354 | ||
|
|
87a1d7c288 | ||
|
|
8e1fe15a24 | ||
|
|
c95c0eff9b | ||
|
|
eaf94f458a | ||
|
|
ba85596e79 | ||
|
|
c6a478c406 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1,5 @@
|
|||||||
|
src/constants/test_firebase.json
|
||||||
|
|
||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
|
|||||||
7594
package-lock.json
generated
7594
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -11,6 +11,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@beam-australia/react-env": "^3.1.1",
|
"@beam-australia/react-env": "^3.1.1",
|
||||||
|
"@dnd-kit/core": "^6.1.0",
|
||||||
|
"@firebase/util": "^1.9.7",
|
||||||
"@headlessui/react": "^1.7.13",
|
"@headlessui/react": "^1.7.13",
|
||||||
"@mdi/js": "^7.1.96",
|
"@mdi/js": "^7.1.96",
|
||||||
"@mdi/react": "^1.6.1",
|
"@mdi/react": "^1.6.1",
|
||||||
@@ -66,9 +68,10 @@
|
|||||||
"react-select": "^5.7.5",
|
"react-select": "^5.7.5",
|
||||||
"react-string-replace": "^1.1.0",
|
"react-string-replace": "^1.1.0",
|
||||||
"react-toastify": "^9.1.2",
|
"react-toastify": "^9.1.2",
|
||||||
|
"react-tooltip": "^5.27.1",
|
||||||
"react-xarrows": "^2.0.2",
|
"react-xarrows": "^2.0.2",
|
||||||
"read-excel-file": "^5.7.1",
|
"read-excel-file": "^5.7.1",
|
||||||
"short-unique-id": "^5.0.2",
|
"short-unique-id": "5.0.2",
|
||||||
"stripe": "^13.10.0",
|
"stripe": "^13.10.0",
|
||||||
"swr": "^2.1.3",
|
"swr": "^2.1.3",
|
||||||
"tailwind-scrollbar-hide": "^1.1.7",
|
"tailwind-scrollbar-hide": "^1.1.7",
|
||||||
@@ -94,7 +97,6 @@
|
|||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
"husky": "^8.0.3",
|
"husky": "^8.0.3",
|
||||||
"postcss": "^8.4.21",
|
"postcss": "^8.4.21",
|
||||||
"tailwindcss": "^3.2.4",
|
"tailwindcss": "^3.2.4"
|
||||||
"types/": "paypal/react-paypal-js"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BIN
public/manuals/corporate.pdf
Normal file
BIN
public/manuals/corporate.pdf
Normal file
Binary file not shown.
BIN
public/manuals/student.pdf
Normal file
BIN
public/manuals/student.pdf
Normal file
Binary file not shown.
BIN
public/manuals/teacher.pdf
Normal file
BIN
public/manuals/teacher.pdf
Normal file
Binary file not shown.
1
public/mat-icon-info.svg
Normal file
1
public/mat-icon-info.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M440-280h80v-240h-80v240Zm40-320q17 0 28.5-11.5T520-640q0-17-11.5-28.5T480-680q-17 0-28.5 11.5T440-640q0 17 11.5 28.5T480-600Zm0 520q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 535 B |
193
src/components/AIDetection.tsx
Normal file
193
src/components/AIDetection.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import RadialProgressBar from "./RadialProgressBar";
|
||||||
|
import { AIDetectionAttributes } from "@/interfaces/exam";
|
||||||
|
import { Tooltip } from 'react-tooltip';
|
||||||
|
import SegmentedProgressBar from "./SegmentedProgressBar";
|
||||||
|
|
||||||
|
|
||||||
|
// Colors and texts scrapped from gpt's zero react bundle
|
||||||
|
const AIDetection: React.FC<AIDetectionAttributes> = ({ predicted_class, confidence_category, class_probabilities, sentences }) => {
|
||||||
|
const probabilityTooltipContent = `
|
||||||
|
GTP's Zero deep learning model predicts the <br/>
|
||||||
|
probability this text has been entirely <br/>
|
||||||
|
generated by AI. For instance, a 40% AI <br/>
|
||||||
|
probability does not indicate that the text<br/>
|
||||||
|
contains 40% AI-written content. Rather, it<br/>
|
||||||
|
indicates the text is more likely to be partially<br/>
|
||||||
|
human written than be entirely AI-written.
|
||||||
|
`;
|
||||||
|
const confidenceTooltipContent = `
|
||||||
|
Confidence scores are a safeguard to better<br/>
|
||||||
|
understand AI identification results. GTP Zero<br/>
|
||||||
|
trained it's deep learning model on a diverse<br/>
|
||||||
|
dataset of millions of human and AI-written<br/>
|
||||||
|
documents. Green scores indicate that you can scan<br/>
|
||||||
|
with confidence that the model has classified<br/>
|
||||||
|
many similar documents with high accuracy.<br/>
|
||||||
|
Red scores indicate that this text is dissimilar<br/>
|
||||||
|
to the ones in their training set, which can impact<br/>
|
||||||
|
the model's accuracy, and to proceed with caution.
|
||||||
|
`;
|
||||||
|
const confidenceKeywords = ["moderately", "highly", "confident", "uncertain"];
|
||||||
|
var confidence = {
|
||||||
|
low: {
|
||||||
|
ai: "GPT Zero is uncertain about this text. If GPT Zero had to classify it, it would be considered",
|
||||||
|
human: "GPT Zero is uncertain about this text. If GPT Zero had to classify it, it would likely be considered",
|
||||||
|
mixed: "GPT Zero is uncertain about this text. If GPT Zero had to classify it, it would likely be a"
|
||||||
|
},
|
||||||
|
medium: {
|
||||||
|
ai: "GPT Zero is moderately confident this text was",
|
||||||
|
human: "GPT Zero is moderately confident this text is entirely",
|
||||||
|
mixed: "GPT Zero is moderately confident this text is a"
|
||||||
|
},
|
||||||
|
high: {
|
||||||
|
ai: "GPT Zero is highly confident this text was",
|
||||||
|
human: "GPT Zero is highly confident this text is entirely",
|
||||||
|
mixed: "GPT Zero is highly confident this text is a"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var classPrediction = {
|
||||||
|
ai: {
|
||||||
|
background: "bg-ai-detection-result-ai-bg",
|
||||||
|
color: "text-ai-detection-result-ai",
|
||||||
|
text: "ai generated"
|
||||||
|
},
|
||||||
|
mixed: {
|
||||||
|
background: "bg-ai-detection-result-mixed-bg",
|
||||||
|
color: "text-ai-detection-result-mixed",
|
||||||
|
text: "mix of ai and human"
|
||||||
|
},
|
||||||
|
human: {
|
||||||
|
background: "bg-ai-detection-result-human-bg",
|
||||||
|
color: "text-ai-detection-result-human",
|
||||||
|
text: "human"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const segments = [
|
||||||
|
{ percentage: Math.round(class_probabilities["human"] * 100), subtitle: 'human', color: "ai-detection-result-human" },
|
||||||
|
{ percentage: Math.round(class_probabilities["mixed"] * 100), subtitle: 'mixed', color: "ai-detection-result-mixed" },
|
||||||
|
{ percentage: Math.round(class_probabilities["ai"] * 100), subtitle: 'ai', color: "ai-detection-result-ai" }
|
||||||
|
];
|
||||||
|
const styleConfidenceText = (text: string): [string, string[]] => {
|
||||||
|
const keywords: string[] = [];
|
||||||
|
const styledText = text.split(" ").map(word => {
|
||||||
|
if (confidenceKeywords.includes(word)) {
|
||||||
|
keywords.push(word);
|
||||||
|
return `<span style="font-weight: 500; text-decoration: underline;">${word}</span>`;
|
||||||
|
}
|
||||||
|
return word
|
||||||
|
}).join(" ");
|
||||||
|
return [styledText, keywords];
|
||||||
|
};
|
||||||
|
const confidenceText = confidence[confidence_category][predicted_class];
|
||||||
|
const [styledText, keywords] = styleConfidenceText(confidenceText);
|
||||||
|
const tooltipStyle = {
|
||||||
|
"backgroundColor": "rgb(255, 255, 255)",
|
||||||
|
"color": "#8992B1",
|
||||||
|
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||||
|
borderRadius: '0.125rem'
|
||||||
|
}
|
||||||
|
const highestProbability = Math.max(class_probabilities["ai"], class_probabilities["human"], class_probabilities["mixed"]);
|
||||||
|
const spanTextColor = highestProbability === class_probabilities["ai"]
|
||||||
|
? "#f4bf4f"
|
||||||
|
: highestProbability === class_probabilities["human"]
|
||||||
|
? "#50c08a"
|
||||||
|
: "#93aafb";
|
||||||
|
let spanClassName = highestProbability === class_probabilities["ai"]
|
||||||
|
? "text-ai-detection-result-ai"
|
||||||
|
: highestProbability === class_probabilities["human"]
|
||||||
|
? "text-ai-detection-result-human"
|
||||||
|
: "text-ai-detection-result-mixed";
|
||||||
|
spanClassName = `${spanClassName} font-bold text-lg`
|
||||||
|
const percentage = Math.round(highestProbability * 100)
|
||||||
|
const hasHighlightedForAI = sentences.some(item => item.highlight_sentence_for_ai);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip id="probability-tooltip" className="z-50 bg-white shadow-md rounded-sm" style={tooltipStyle} />
|
||||||
|
<Tooltip id="confidence-tooltip" className="z-50 bg-white shadow-md rounded-sm" style={tooltipStyle} />
|
||||||
|
<div className="flex flex-col bg-white p-6 rounded-lg shadow-lg gap-16">
|
||||||
|
<h1 className="text-lg font-semibold">GPT Zero AI Detection Results</h1>
|
||||||
|
<div className="flex flex-row -md:flex-col -lg:gap-0 -xl:gap-10 gap-20 items-stretch -md:items-center">
|
||||||
|
<div className="flex -md:w-5/6 w-1/2 justify-center">
|
||||||
|
<div className="flex flex-col border rounded-xl">
|
||||||
|
<h1 className="border-b p-6 font-medium">Text Classification</h1>
|
||||||
|
<div className="flex flex-row gap-8 items-center p-6">
|
||||||
|
<RadialProgressBar
|
||||||
|
percentage={percentage}
|
||||||
|
text={predicted_class}
|
||||||
|
color={spanTextColor}
|
||||||
|
spanClassName={spanClassName}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1 text-sm">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<span className="mr-2 text-ai-detection-result-ai-text font-semibold text-xl">
|
||||||
|
{`${Math.round(class_probabilities["ai"] * 100)}%`}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm -md:text-xs text-ai-detection-text">Probability AI generated</span>
|
||||||
|
<a data-tooltip-id="probability-tooltip" data-tooltip-html={probabilityTooltipContent} className='ml-1 flex items-center justify-center'>
|
||||||
|
<Image src="/mat-icon-info.svg" width={24} height={24} alt="Probability Tooltip" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-center gap-1">
|
||||||
|
<div className={clsx(
|
||||||
|
"rounded-full w-3 h-3",
|
||||||
|
confidence_category == 'low' ?
|
||||||
|
"bg-ai-detection-confidence-low border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-low-transparent"
|
||||||
|
)}></div>
|
||||||
|
<div className={clsx(
|
||||||
|
"rounded-full w-3 h-3",
|
||||||
|
confidence_category == 'medium' ?
|
||||||
|
"bg-ai-detection-confidence-medium border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-medium-transparent"
|
||||||
|
)}></div>
|
||||||
|
<div className={clsx(
|
||||||
|
"rounded-full w-3 h-3 mr-2",
|
||||||
|
confidence_category == 'high' ?
|
||||||
|
"bg-ai-detection-confidence-high border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-high-transparent"
|
||||||
|
)}></div>
|
||||||
|
<span className="text-sm -md:text-xs text-ai-detection-text">{keywords.join(' ')}</span>
|
||||||
|
<a data-tooltip-id="confidence-tooltip" data-tooltip-html={confidenceTooltipContent} className='ml-1 flex items-center justify-center'>
|
||||||
|
<Image src="/mat-icon-info.svg" width={24} height={24} alt="Probability Tooltip" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col border rounded-xl -md:w-5/6 w-2/6">
|
||||||
|
<h1 className="border-b p-6 font-medium">Probability Breakdown</h1>
|
||||||
|
<div className="flex items-center w-full h-full">
|
||||||
|
<SegmentedProgressBar segments={segments} className="w-full px-8 -md:py-8 text-ai-detection-text" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: styledText }} className="mr-2"></div>
|
||||||
|
<div className={clsx(
|
||||||
|
"flex items-center justify-center p-2 rounded",
|
||||||
|
classPrediction[predicted_class]['color'],
|
||||||
|
classPrediction[predicted_class]['background']
|
||||||
|
)}>
|
||||||
|
<span className="text-sm">{classPrediction[predicted_class]['text']}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(hasHighlightedForAI && <div>
|
||||||
|
Sentences that are likely written by AI are <span className="font-semibold bg-ai-detection-highlight">highlighted</span>.
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
</div >
|
||||||
|
<div>
|
||||||
|
{sentences.map((item, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={item.highlight_sentence_for_ai ? 'bg-ai-detection-highlight' : ''}
|
||||||
|
>
|
||||||
|
{item.sentence}{' '}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default AIDetection;
|
||||||
@@ -77,15 +77,8 @@ export default function FillBlanks({
|
|||||||
onBack,
|
onBack,
|
||||||
}: FillBlanksExercise & CommonProps) {
|
}: FillBlanksExercise & CommonProps) {
|
||||||
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
|
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
|
||||||
const [currentBlankId, setCurrentBlankId] = useState<string>();
|
|
||||||
const [isDrawerShowing, setIsDrawerShowing] = useState(false);
|
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
const allBlanks = Array.from(text.match(/({{\d+}})/g) || []).map((x) => x.replaceAll("{", "").replaceAll("}", ""));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTimeout(() => setIsDrawerShowing(!!currentBlankId), 100);
|
|
||||||
}, [currentBlankId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
@@ -94,9 +87,17 @@ export default function FillBlanks({
|
|||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||||
const correct = answers.filter(
|
const correct = answers.filter((x) => {
|
||||||
(x) => solutions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false,
|
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution.toLowerCase();
|
||||||
).length;
|
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;
|
const missing = total - answers.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||||
|
|
||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
@@ -104,49 +105,29 @@ export default function FillBlanks({
|
|||||||
|
|
||||||
const renderLines = (line: string) => {
|
const renderLines = (line: string) => {
|
||||||
return (
|
return (
|
||||||
<span className="text-base leading-5">
|
<div className="text-base leading-5">
|
||||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||||
const id = match.replaceAll(/[\{\}]/g, "");
|
const id = match.replaceAll(/[\{\}]/g, "");
|
||||||
const userSolution = answers.find((x) => x.id === id);
|
const userSolution = answers.find((x) => x.id === id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<input
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
|
"rounded-full hover:text-white 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 && "w-6 h-6 text-center text-mti-purple-light bg-mti-purple-ultralight",
|
!userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
|
||||||
currentBlankId === id && "text-white !bg-mti-purple-light ",
|
userSolution && "px-5 py-2 text-center text-mti-purple-dark bg-mti-purple-ultralight",
|
||||||
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
|
|
||||||
)}
|
)}
|
||||||
onClick={() => setCurrentBlankId(id)}>
|
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), {id, solution: e.target.value}])}
|
||||||
{userSolution ? userSolution.solution : id}
|
value={userSolution?.solution}></input>
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</span>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
{(!!currentBlankId || isDrawerShowing) && (
|
|
||||||
<WordsDrawer
|
|
||||||
key={currentBlankId}
|
|
||||||
blankId={currentBlankId}
|
|
||||||
words={words.map((word) => ({word, isDisabled: allowRepetition ? false : answers.map((x) => x.solution).includes(word)}))}
|
|
||||||
previouslySelectedWord={currentBlankId ? answers.find((x) => x.id === currentBlankId)?.solution : undefined}
|
|
||||||
isOpen={isDrawerShowing}
|
|
||||||
onCancel={() => setCurrentBlankId(undefined)}
|
|
||||||
onAnswer={(solution: string) => {
|
|
||||||
setAnswers((prev) => [...prev.filter((x) => x.id !== currentBlankId), {id: currentBlankId!, solution}]);
|
|
||||||
if (allBlanks.findIndex((x) => x === currentBlankId) + 1 < allBlanks.length) {
|
|
||||||
setCurrentBlankId(allBlanks[allBlanks.findIndex((x) => x === currentBlankId) + 1]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCurrentBlankId(undefined);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
@@ -163,6 +144,26 @@ export default function FillBlanks({
|
|||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
</span>
|
</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>
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mo
|
|||||||
export default function InteractiveSpeaking({
|
export default function InteractiveSpeaking({
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
|
first_title,
|
||||||
|
second_title,
|
||||||
examID,
|
examID,
|
||||||
text,
|
|
||||||
type,
|
type,
|
||||||
prompts,
|
prompts,
|
||||||
userSolutions,
|
userSolutions,
|
||||||
updateIndex,
|
|
||||||
onNext,
|
onNext,
|
||||||
onBack,
|
onBack,
|
||||||
}: InteractiveSpeakingExercise & CommonProps) {
|
}: InteractiveSpeakingExercise & CommonProps) {
|
||||||
@@ -36,31 +36,6 @@ export default function InteractiveSpeaking({
|
|||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
const saveToStorage = async (previousURL?: string) => {
|
|
||||||
if (mediaBlob && mediaBlob.startsWith("blob")) {
|
|
||||||
const blobBuffer = await downloadBlob(mediaBlob);
|
|
||||||
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
|
|
||||||
|
|
||||||
const seed = Math.random().toString().replace("0.", "");
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("audio", audioFile, `${seed}.wav`);
|
|
||||||
formData.append("root", "speaking_recordings");
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "audio/wav",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await axios.post<{path: string}>("/api/storage/insert", formData, config);
|
|
||||||
if (previousURL && !previousURL.startsWith("blob")) await axios.post("/api/storage/delete", {path: previousURL});
|
|
||||||
return response.data.path;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const back = async () => {
|
const back = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
@@ -91,7 +66,9 @@ export default function InteractiveSpeaking({
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
setQuestionIndex(0);
|
||||||
|
|
||||||
onNext({
|
onNext({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
@@ -103,7 +80,6 @@ export default function InteractiveSpeaking({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userSolutions.length > 0 && answers.length === 0) {
|
if (userSolutions.length > 0 && answers.length === 0) {
|
||||||
console.log(userSolutions);
|
|
||||||
const solutions = userSolutions as unknown as typeof answers;
|
const solutions = userSolutions as unknown as typeof answers;
|
||||||
setAnswers(solutions);
|
setAnswers(solutions);
|
||||||
|
|
||||||
@@ -112,14 +88,6 @@ export default function InteractiveSpeaking({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [userSolutions, mediaBlob, answers]);
|
}, [userSolutions, mediaBlob, answers]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log({answers});
|
|
||||||
}, [answers]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (updateIndex) updateIndex(questionIndex);
|
|
||||||
}, [questionIndex, updateIndex]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) {
|
if (hasExamEnded) {
|
||||||
const answer = {
|
const answer = {
|
||||||
@@ -159,13 +127,10 @@ export default function InteractiveSpeaking({
|
|||||||
}, [answers, questionIndex]);
|
}, [answers, questionIndex]);
|
||||||
|
|
||||||
const saveAnswer = async (index: number) => {
|
const saveAnswer = async (index: number) => {
|
||||||
const previousURL = answers.find((x) => x.questionIndex === questionIndex)?.blob;
|
|
||||||
const audioPath = await saveToStorage(previousURL);
|
|
||||||
|
|
||||||
const answer = {
|
const answer = {
|
||||||
questionIndex,
|
questionIndex,
|
||||||
prompt: prompts[questionIndex].text,
|
prompt: prompts[questionIndex].text,
|
||||||
blob: audioPath ? audioPath : mediaBlob!,
|
blob: mediaBlob!,
|
||||||
};
|
};
|
||||||
|
|
||||||
setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]);
|
setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]);
|
||||||
@@ -190,7 +155,7 @@ export default function InteractiveSpeaking({
|
|||||||
<div className="flex flex-col h-full w-full gap-9">
|
<div className="flex flex-col h-full w-full gap-9">
|
||||||
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<span className="font-semibold">{title}</span>
|
<span className="font-semibold">{!!first_title && !!second_title ? `${first_title} & ${second_title}` : title}</span>
|
||||||
</div>
|
</div>
|
||||||
{prompts && prompts.length > 0 && (
|
{prompts && prompts.length > 0 && (
|
||||||
<div className="flex flex-col gap-4 w-full items-center">
|
<div className="flex flex-col gap-4 w-full items-center">
|
||||||
@@ -209,7 +174,7 @@ export default function InteractiveSpeaking({
|
|||||||
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
<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>
|
<p className="text-base font-normal">Record your answer:</p>
|
||||||
<div className="flex gap-8 items-center justify-center py-8">
|
<div className="flex gap-8 items-center justify-center py-8">
|
||||||
{status === "idle" && !mediaBlob && (
|
{status === "idle" && (
|
||||||
<>
|
<>
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
{status === "idle" && (
|
{status === "idle" && (
|
||||||
@@ -288,9 +253,9 @@ export default function InteractiveSpeaking({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
|
{status === "stopped" && mediaBlobUrl && (
|
||||||
<>
|
<>
|
||||||
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<BsTrashFill
|
<BsTrashFill
|
||||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
|
||||||
import {MatchSentencesExercise} from "@/interfaces/exam";
|
import {MatchSentenceExerciseOption, MatchSentenceExerciseSentence, MatchSentencesExercise} from "@/interfaces/exam";
|
||||||
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
|
||||||
import Icon from "@mdi/react";
|
import Icon from "@mdi/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@@ -9,13 +9,74 @@ import {CommonProps} from ".";
|
|||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import Xarrow from "react-xarrows";
|
import Xarrow from "react-xarrows";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import {DndContext, DragEndEvent, useDraggable, useDroppable} from "@dnd-kit/core";
|
||||||
|
|
||||||
|
function DroppableQuestionArea({question, answer}: {question: MatchSentenceExerciseSentence; answer?: string}) {
|
||||||
|
const {isOver, setNodeRef} = useDroppable({id: `droppable_sentence_${question.id}`});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-4" ref={setNodeRef}>
|
||||||
|
<div className="flex items-center gap-3 cursor-pointer col-span-2">
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
)}>
|
||||||
|
{question.id}
|
||||||
|
</button>
|
||||||
|
<span>{question.sentence}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
key={`answer_${question.id}_${answer}`}
|
||||||
|
className={clsx("w-48 h-10 border rounded-xl flex items-center justify-center", isOver && "border-mti-purple-light")}>
|
||||||
|
{answer && `Paragraph ${answer}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraggableOptionArea({option}: {option: MatchSentenceExerciseOption}) {
|
||||||
|
const {attributes, listeners, setNodeRef, transform} = useDraggable({
|
||||||
|
id: `draggable_option_${option.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = transform
|
||||||
|
? {
|
||||||
|
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||||
|
zIndex: 99,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx("flex items-center justify-start gap-6 cursor-pointer")} ref={setNodeRef} style={style} {...listeners} {...attributes}>
|
||||||
|
<button
|
||||||
|
id={`option_${option.id}`}
|
||||||
|
// onClick={() => selectOption(id)}
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple px-3 py-2 rounded-full z-10",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
option.id,
|
||||||
|
)}>
|
||||||
|
Paragraph {option.id}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function MatchSentences({id, options, type, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
|
export default function MatchSentences({id, options, type, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
|
||||||
const [selectedQuestion, setSelectedQuestion] = useState<string>();
|
|
||||||
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
if (event.over && event.over.id.toString().startsWith("droppable")) {
|
||||||
|
const optionID = event.active.id.toString().replace("draggable_option_", "");
|
||||||
|
const sentenceID = event.over.id.toString().replace("droppable_sentence_", "");
|
||||||
|
|
||||||
|
setAnswers((prev) => [...prev.filter((x) => x.question.toString() !== sentenceID), {question: sentenceID, option: optionID}]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = sentences.length;
|
const total = sentences.length;
|
||||||
const correct = answers.filter(
|
const correct = answers.filter(
|
||||||
@@ -26,12 +87,6 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectOption = (option: string) => {
|
|
||||||
if (!selectedQuestion) return;
|
|
||||||
setAnswers((prev) => [...prev.filter((x) => x.question !== selectedQuestion), {question: selectedQuestion, option}]);
|
|
||||||
setSelectedQuestion(undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -39,7 +94,7 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
@@ -48,46 +103,28 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
|
||||||
<div className="flex flex-col gap-4">
|
<DndContext onDragEnd={handleDragEnd}>
|
||||||
{sentences.map(({sentence, id}) => (
|
<div className="flex flex-col gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
||||||
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
|
<div className="flex flex-col gap-4">
|
||||||
<span>{sentence} </span>
|
{sentences.map((question) => (
|
||||||
<button
|
<DroppableQuestionArea
|
||||||
id={id}
|
key={`question_${question.id}`}
|
||||||
onClick={() => setSelectedQuestion((prev) => (prev === id ? undefined : id))}
|
question={question}
|
||||||
className={clsx(
|
answer={answers.find((x) => x.question.toString() === question.id.toString())?.option}
|
||||||
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
/>
|
||||||
"transition duration-300 ease-in-out",
|
))}
|
||||||
selectedQuestion === id && "!text-white !bg-mti-purple",
|
</div>
|
||||||
id,
|
<div className="flex flex-col gap-4">
|
||||||
)}>
|
<span>Drag one of these paragraphs into the slots above:</span>
|
||||||
{id}
|
<div className="flex gap-4 flex-wrap justify-center items-center max-w-lg">
|
||||||
</button>
|
{options.map((option) => (
|
||||||
|
<DraggableOptionArea key={`answer_${option.id}`} option={option} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
</DndContext>
|
||||||
{options.map(({sentence, id}) => (
|
|
||||||
<div key={`answer_${id}`} className={clsx("flex items-center justify-start gap-2 cursor-pointer")}>
|
|
||||||
<button
|
|
||||||
id={id}
|
|
||||||
onClick={() => selectOption(id)}
|
|
||||||
className={clsx(
|
|
||||||
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
id,
|
|
||||||
)}>
|
|
||||||
{id}
|
|
||||||
</button>
|
|
||||||
<span>{sentence}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{answers.map((solution, index) => (
|
|
||||||
<Xarrow key={index} start={solution.question} end={solution.option} lineColor="#7872BF" showHead={false} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
|
|||||||
@@ -3,19 +3,36 @@ import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam"
|
|||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
import reactStringReplace from "react-string-replace";
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
|
|
||||||
function Question({
|
function Question({
|
||||||
|
id,
|
||||||
variant,
|
variant,
|
||||||
prompt,
|
prompt,
|
||||||
options,
|
options,
|
||||||
userSolution,
|
userSolution,
|
||||||
onSelectOption,
|
onSelectOption,
|
||||||
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
||||||
|
const renderPrompt = (prompt: string) => {
|
||||||
|
return reactStringReplace(prompt, /((<u>)[\w\s']+(<\/u>))/g, (match) => {
|
||||||
|
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
|
||||||
|
return word.length > 0 ? <u>{word}</u> : null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-10">
|
<div className="flex flex-col gap-10">
|
||||||
<span className="">{prompt}</span>
|
{isNaN(Number(id)) ? (
|
||||||
|
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||||
|
) : (
|
||||||
|
<span className="">
|
||||||
|
<>
|
||||||
|
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||||
|
</>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div className="flex flex-wrap gap-4 justify-between">
|
<div className="flex flex-wrap gap-4 justify-between">
|
||||||
{variant === "image" &&
|
{variant === "image" &&
|
||||||
options.map((option) => (
|
options.map((option) => (
|
||||||
@@ -48,16 +65,7 @@ function Question({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MultipleChoice({
|
export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
||||||
id,
|
|
||||||
prompt,
|
|
||||||
type,
|
|
||||||
questions,
|
|
||||||
userSolutions,
|
|
||||||
updateIndex,
|
|
||||||
onNext,
|
|
||||||
onBack,
|
|
||||||
}: MultipleChoiceExercise & CommonProps) {
|
|
||||||
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||||
|
|
||||||
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||||
@@ -76,10 +84,6 @@ export default function MultipleChoice({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [hasExamEnded]);
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (updateIndex) updateIndex(questionIndex);
|
|
||||||
}, [questionIndex, updateIndex]);
|
|
||||||
|
|
||||||
const onSelectOption = (option: string) => {
|
const onSelectOption = (option: string) => {
|
||||||
const question = questions[questionIndex];
|
const question = questions[questionIndex];
|
||||||
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
|
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
|
||||||
@@ -117,7 +121,7 @@ export default function MultipleChoice({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-2 mt-4 h-fit mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8">
|
<div className="flex 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>
|
<span className="text-xl font-semibold">{prompt}</span>
|
||||||
{questionIndex < questions.length && (
|
{questionIndex < questions.length && (
|
||||||
<Question
|
<Question
|
||||||
|
|||||||
@@ -7,18 +7,20 @@ import Button from "../Low/Button";
|
|||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {downloadBlob} from "@/utils/evaluation";
|
import {downloadBlob} from "@/utils/evaluation";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
|
||||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||||
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function Speaking({id, title, text, video_url, type, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
export default function Speaking({id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||||
const [audioURL, setAudioURL] = useState<string>();
|
const [audioURL, setAudioURL] = useState<string>();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isPromptsModalOpen, setIsPromptsModalOpen] = useState(false);
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
@@ -74,36 +76,45 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
|
|||||||
}, [isRecording]);
|
}, [isRecording]);
|
||||||
|
|
||||||
const next = async () => {
|
const next = async () => {
|
||||||
setIsLoading(true);
|
|
||||||
const storagePath = await saveToStorage();
|
|
||||||
setIsLoading(false);
|
|
||||||
|
|
||||||
onNext({
|
onNext({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: storagePath ? [{id, solution: storagePath}] : [],
|
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||||
score: {correct: 100, total: 100, missing: 0},
|
score: {correct: 0, total: 100, missing: 0},
|
||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const back = async () => {
|
const back = async () => {
|
||||||
setIsLoading(true);
|
|
||||||
const storagePath = await saveToStorage();
|
|
||||||
setIsLoading(false);
|
|
||||||
|
|
||||||
onBack({
|
onBack({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: storagePath ? [{id, solution: storagePath}] : [],
|
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||||
score: {correct: 100, total: 100, missing: 0},
|
score: {correct: 0, total: 100, missing: 0},
|
||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full gap-9">
|
<div className="flex flex-col h-full w-full gap-9">
|
||||||
|
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
|
||||||
|
<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 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-3">
|
||||||
<span className="font-semibold">{title}</span>
|
<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 30 seconds for your answer to be valid.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{!video_url && (
|
{!video_url && (
|
||||||
<span className="font-regular">
|
<span className="font-regular">
|
||||||
{text.split("\\n").map((line, index) => (
|
{text.split("\\n").map((line, index) => (
|
||||||
@@ -115,7 +126,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-6">
|
<div className="flex flex-col gap-6 items-center">
|
||||||
{video_url && (
|
{video_url && (
|
||||||
<div className="flex flex-col gap-4 w-full items-center">
|
<div className="flex flex-col gap-4 w-full items-center">
|
||||||
<video key={id} autoPlay controls className="max-w-3xl rounded-xl">
|
<video key={id} autoPlay controls className="max-w-3xl rounded-xl">
|
||||||
@@ -123,18 +134,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
|
|||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{prompts && prompts.length > 0 && (
|
{prompts && prompts.length > 0 && <Button onClick={() => setIsPromptsModalOpen(true)}>View Prompts</Button>}
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<span className="font-bold">You should talk about the following things:</span>
|
|
||||||
<div className="flex flex-col gap-1 ml-4">
|
|
||||||
{prompts.map((x, index) => (
|
|
||||||
<li className="italic" key={index}>
|
|
||||||
{x}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<span key={index}>
|
<span key={index}>
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentenc
|
|||||||
|
|
||||||
export interface CommonProps {
|
export interface CommonProps {
|
||||||
examID?: string;
|
examID?: string;
|
||||||
updateIndex?: (internalIndex: number) => void;
|
|
||||||
onNext: (userSolutions: UserSolution) => void;
|
onNext: (userSolutions: UserSolution) => void;
|
||||||
onBack: (userSolutions: UserSolution) => void;
|
onBack: (userSolutions: UserSolution) => void;
|
||||||
}
|
}
|
||||||
@@ -33,7 +32,6 @@ export const renderExercise = (
|
|||||||
examID: string,
|
examID: string,
|
||||||
onNext: (userSolutions: UserSolution) => void,
|
onNext: (userSolutions: UserSolution) => void,
|
||||||
onBack: (userSolutions: UserSolution) => void,
|
onBack: (userSolutions: UserSolution) => void,
|
||||||
updateIndex?: (internalIndex: number) => void,
|
|
||||||
) => {
|
) => {
|
||||||
switch (exercise.type) {
|
switch (exercise.type) {
|
||||||
case "fillBlanks":
|
case "fillBlanks":
|
||||||
@@ -43,16 +41,7 @@ export const renderExercise = (
|
|||||||
case "matchSentences":
|
case "matchSentences":
|
||||||
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||||
case "multipleChoice":
|
case "multipleChoice":
|
||||||
return (
|
return <MultipleChoice key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||||
<MultipleChoice
|
|
||||||
key={exercise.id}
|
|
||||||
{...(exercise as MultipleChoiceExercise)}
|
|
||||||
updateIndex={updateIndex}
|
|
||||||
onNext={onNext}
|
|
||||||
onBack={onBack}
|
|
||||||
examID={examID}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "writeBlanks":
|
case "writeBlanks":
|
||||||
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||||
case "writing":
|
case "writing":
|
||||||
@@ -65,7 +54,6 @@ export const renderExercise = (
|
|||||||
key={exercise.id}
|
key={exercise.id}
|
||||||
{...(exercise as InteractiveSpeakingExercise)}
|
{...(exercise as InteractiveSpeakingExercise)}
|
||||||
examID={examID}
|
examID={examID}
|
||||||
updateIndex={updateIndex}
|
|
||||||
onNext={onNext}
|
onNext={onNext}
|
||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
/>
|
/>
|
||||||
|
|||||||
82
src/components/Generation/fill.blanks.edit.tsx
Normal file
82
src/components/Generation/fill.blanks.edit.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import {FillBlanksExercise} from "@/interfaces/exam";
|
||||||
|
import React from "react";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
exercise: FillBlanksExercise;
|
||||||
|
updateExercise: (data: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FillBlanksEdit = (props: Props) => {
|
||||||
|
const {exercise, updateExercise} = props;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Prompt"
|
||||||
|
name="prompt"
|
||||||
|
required
|
||||||
|
value={exercise.prompt}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
prompt: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Text"
|
||||||
|
name="text"
|
||||||
|
required
|
||||||
|
value={exercise.text}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
text: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<h1>Solutions</h1>
|
||||||
|
<div className="w-full flex flex-wrap -mx-2">
|
||||||
|
{exercise.solutions.map((solution, index) => (
|
||||||
|
<div key={solution.id} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label={`Solution ${index + 1}`}
|
||||||
|
name="solution"
|
||||||
|
required
|
||||||
|
value={solution.solution}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
solutions: exercise.solutions.map((sol) => (sol.id === solution.id ? {...sol, solution: value} : sol)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<h1>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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FillBlanksEdit;
|
||||||
7
src/components/Generation/interactive.speaking.edit.tsx
Normal file
7
src/components/Generation/interactive.speaking.edit.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const InteractiveSpeakingEdit = () => {
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InteractiveSpeakingEdit;
|
||||||
130
src/components/Generation/match.sentences.edit.tsx
Normal file
130
src/components/Generation/match.sentences.edit.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { MatchSentencesExercise } from "@/interfaces/exam";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
exercise: MatchSentencesExercise;
|
||||||
|
updateExercise: (data: any) => void;
|
||||||
|
}
|
||||||
|
const MatchSentencesEdit = (props: Props) => {
|
||||||
|
const { exercise, updateExercise } = props;
|
||||||
|
|
||||||
|
const selectOptions = exercise.options.map((option) => ({
|
||||||
|
value: option.id,
|
||||||
|
label: option.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Prompt"
|
||||||
|
name="prompt"
|
||||||
|
required
|
||||||
|
value={exercise.prompt}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
prompt: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<h1>Solutions</h1>
|
||||||
|
<div className="w-full flex flex-wrap -mx-2">
|
||||||
|
{exercise.sentences.map((sentence, index) => (
|
||||||
|
<div key={sentence.id} className="flex flex-col w-full px-2">
|
||||||
|
<div className="flex w-full">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label={`Sentence ${index + 1}`}
|
||||||
|
name="sentence"
|
||||||
|
required
|
||||||
|
value={sentence.sentence}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
sentences: exercise.sentences.map((iSol) =>
|
||||||
|
iSol.id === sentence.id
|
||||||
|
? {
|
||||||
|
...iSol,
|
||||||
|
sentence: value,
|
||||||
|
}
|
||||||
|
: iSol
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="px-2"
|
||||||
|
/>
|
||||||
|
<div className="w-48 flex items-end px-2">
|
||||||
|
<Select
|
||||||
|
value={selectOptions.find(
|
||||||
|
(o) => o.value === sentence.solution
|
||||||
|
)}
|
||||||
|
options={selectOptions}
|
||||||
|
onChange={(value) => {
|
||||||
|
updateExercise({
|
||||||
|
sentences: exercise.sentences.map((iSol) =>
|
||||||
|
iSol.id === sentence.id
|
||||||
|
? {
|
||||||
|
...iSol,
|
||||||
|
solution: value?.value,
|
||||||
|
}
|
||||||
|
: iSol
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<h1>Options</h1>
|
||||||
|
<div className="w-full flex flex-wrap -mx-2">
|
||||||
|
{exercise.options.map((option, index) => (
|
||||||
|
<div key={option.id} className="flex flex-col w-full px-2">
|
||||||
|
<div className="flex w-full">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label={`Option ${index + 1}`}
|
||||||
|
name="option"
|
||||||
|
required
|
||||||
|
value={option.sentence}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
options: exercise.options.map((iSol) =>
|
||||||
|
iSol.id === option.id
|
||||||
|
? {
|
||||||
|
...iSol,
|
||||||
|
sentence: value,
|
||||||
|
}
|
||||||
|
: iSol
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="px-2"
|
||||||
|
/>
|
||||||
|
<div className="w-48 flex items-end px-2">
|
||||||
|
<Select
|
||||||
|
value={{
|
||||||
|
value: option.id,
|
||||||
|
label: option.id,
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: option.id,
|
||||||
|
label: option.id,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
disabled
|
||||||
|
onChange={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MatchSentencesEdit;
|
||||||
137
src/components/Generation/multiple.choice.edit.tsx
Normal file
137
src/components/Generation/multiple.choice.edit.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import {
|
||||||
|
MultipleChoiceExercise,
|
||||||
|
MultipleChoiceQuestion,
|
||||||
|
} from "@/interfaces/exam";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
exercise: MultipleChoiceExercise;
|
||||||
|
updateExercise: (data: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantOptions = [
|
||||||
|
{ value: "text", label: "Text", key: "text" },
|
||||||
|
{ value: "image", label: "Image", key: "src" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MultipleChoiceEdit = (props: Props) => {
|
||||||
|
const { exercise, updateExercise } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Questions</h1>
|
||||||
|
<div className="w-full flex-no-wrap -mx-2">
|
||||||
|
{exercise.questions.map((question: MultipleChoiceQuestion, index) => {
|
||||||
|
const variantValue = variantOptions.find(
|
||||||
|
(o) => o.value === question.variant
|
||||||
|
);
|
||||||
|
|
||||||
|
const solutionsOptions = question.options.map((option) => ({
|
||||||
|
value: option.id,
|
||||||
|
label: option.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const solutionValue = solutionsOptions.find(
|
||||||
|
(o) => o.value === question.solution
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div key={question.id} className="flex w-full px-2 flex-col">
|
||||||
|
<span>Question ID: {question.id}</span>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Prompt"
|
||||||
|
name="prompt"
|
||||||
|
required
|
||||||
|
value={question.prompt}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
questions: exercise.questions.map((sol) =>
|
||||||
|
sol.id === question.id ? { ...sol, prompt: value } : sol
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex w-full">
|
||||||
|
<div className="w-48 flex items-end px-2">
|
||||||
|
<Select
|
||||||
|
value={solutionValue}
|
||||||
|
options={solutionsOptions}
|
||||||
|
onChange={(value) => {
|
||||||
|
updateExercise({
|
||||||
|
questions: exercise.questions.map((sol) =>
|
||||||
|
sol.id === question.id
|
||||||
|
? { ...sol, solution: value?.value }
|
||||||
|
: sol
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-48 flex items-end px-2">
|
||||||
|
<Select
|
||||||
|
value={variantValue}
|
||||||
|
options={variantOptions}
|
||||||
|
onChange={(value) => {
|
||||||
|
updateExercise({
|
||||||
|
questions: exercise.questions.map((sol) =>
|
||||||
|
sol.id === question.id
|
||||||
|
? { ...sol, variant: value?.value }
|
||||||
|
: sol
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-wrap -mx-2">
|
||||||
|
{question.options.map((option) => (
|
||||||
|
<div
|
||||||
|
key={option.id}
|
||||||
|
className="flex sm:w-1/2 lg:w-1/4 px-2 px-2"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label={`Option ${option.id}`}
|
||||||
|
name="option"
|
||||||
|
required
|
||||||
|
value={option.text}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
questions: exercise.questions.map((sol) =>
|
||||||
|
sol.id === question.id
|
||||||
|
? {
|
||||||
|
...sol,
|
||||||
|
options: sol.options.map((opt) => {
|
||||||
|
if (
|
||||||
|
opt.id === option.id &&
|
||||||
|
variantValue?.key
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...opt,
|
||||||
|
[variantValue.key]: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return opt;
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: sol
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MultipleChoiceEdit;
|
||||||
7
src/components/Generation/speaking.edit.tsx
Normal file
7
src/components/Generation/speaking.edit.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const SpeakingEdit = () => {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SpeakingEdit;
|
||||||
71
src/components/Generation/true.false.edit.tsx
Normal file
71
src/components/Generation/true.false.edit.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { TrueFalseExercise } from "@/interfaces/exam";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
interface Props {
|
||||||
|
exercise: TrueFalseExercise;
|
||||||
|
updateExercise: (data: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ value: "true", label: "True" },
|
||||||
|
{ value: "false", label: "False" },
|
||||||
|
{ value: "not_given", label: "Not Given" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TrueFalseEdit = (props: Props) => {
|
||||||
|
const { exercise, updateExercise } = props;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Prompt"
|
||||||
|
name="prompt"
|
||||||
|
required
|
||||||
|
value={exercise.prompt}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
prompt: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<h1>Questions</h1>
|
||||||
|
<div className="w-full flex-no-wrap -mx-2">
|
||||||
|
{exercise.questions.map((question, index) => (
|
||||||
|
<div key={question.id} className="flex w-full px-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label={`Question ${index + 1}`}
|
||||||
|
name="question"
|
||||||
|
required
|
||||||
|
value={question.prompt}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
questions: exercise.questions.map((sol) =>
|
||||||
|
sol.id === question.id ? { ...sol, prompt: value } : sol
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="w-48 flex items-end px-2">
|
||||||
|
<Select
|
||||||
|
value={options.find((o) => o.value === question.solution)}
|
||||||
|
options={options}
|
||||||
|
onChange={(value) => {
|
||||||
|
updateExercise({
|
||||||
|
questions: exercise.questions.map((sol) =>
|
||||||
|
sol.id === question.id ? { ...sol, solution: value?.value } : sol
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-18"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrueFalseEdit;
|
||||||
94
src/components/Generation/write.blanks.edit.tsx
Normal file
94
src/components/Generation/write.blanks.edit.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import { WriteBlanksExercise } from "@/interfaces/exam";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
exercise: WriteBlanksExercise;
|
||||||
|
updateExercise: (data: any) => void;
|
||||||
|
}
|
||||||
|
const WriteBlankEdits = (props: Props) => {
|
||||||
|
const { exercise, updateExercise } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Prompt"
|
||||||
|
name="prompt"
|
||||||
|
required
|
||||||
|
value={exercise.prompt}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
prompt: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Text"
|
||||||
|
name="text"
|
||||||
|
required
|
||||||
|
value={exercise.text}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
text: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Max Words"
|
||||||
|
name="number"
|
||||||
|
required
|
||||||
|
value={exercise.maxWords}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
maxWords: Number(value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<h1>Solutions</h1>
|
||||||
|
<div className="w-full flex flex-wrap -mx-2">
|
||||||
|
{exercise.solutions.map((solution) => (
|
||||||
|
<div key={solution.id} className="flex flex-col w-full px-2">
|
||||||
|
<span>Solution ID: {solution.id}</span>
|
||||||
|
{/* TODO: Consider adding an add and delete button */}
|
||||||
|
<div className="flex flex-wrap">
|
||||||
|
{solution.solution.map((sol, solIndex) => (
|
||||||
|
<Input
|
||||||
|
key={`${sol}-${solIndex}`}
|
||||||
|
type="text"
|
||||||
|
label={`Solution ${solIndex + 1}`}
|
||||||
|
name="solution"
|
||||||
|
required
|
||||||
|
value={sol}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
solutions: exercise.solutions.map((iSol) =>
|
||||||
|
iSol.id === solution.id
|
||||||
|
? {
|
||||||
|
...iSol,
|
||||||
|
solution: iSol.solution.map((iiSol, iiIndex) => {
|
||||||
|
if (iiIndex === solIndex) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return iiSol;
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: iSol
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="sm:w-1/2 lg:w-1/4 px-2"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WriteBlankEdits;
|
||||||
7
src/components/Generation/writing.edit.tsx
Normal file
7
src/components/Generation/writing.edit.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const WritingEdit = () => {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WritingEdit;
|
||||||
@@ -33,8 +33,7 @@ export default function Layout({user, children, className, navDisabled = false,
|
|||||||
focusMode={focusMode}
|
focusMode={focusMode}
|
||||||
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
|
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
|
||||||
className="-md:hidden"
|
className="-md:hidden"
|
||||||
userType={user.type}
|
user={user}
|
||||||
userId={user.id}
|
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import ShortUniqueId from "short-unique-id";
|
|||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import Input from "../Low/Input";
|
import Input from "../Low/Input";
|
||||||
import Select from "../Low/Select";
|
import Select from "../Low/Select";
|
||||||
|
import { checkAccess } from "@/utils/permissions";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -137,13 +138,13 @@ export default function TicketDisplay({ user, ticket, onClose }: Props) {
|
|||||||
options={[
|
options={[
|
||||||
{ value: "me", label: "Assign to me" },
|
{ value: "me", label: "Assign to me" },
|
||||||
...users
|
...users
|
||||||
.filter((x) => ["admin", "developer", "agent"].includes(x.type))
|
.filter((x) => checkAccess(x, ["admin", "developer", "agent"]))
|
||||||
.map((u) => ({
|
.map((u) => ({
|
||||||
value: u.id,
|
value: u.id,
|
||||||
label: `${u.name} - ${u.email}`,
|
label: `${u.name} - ${u.email}`,
|
||||||
})),
|
})),
|
||||||
]}
|
]}
|
||||||
disabled={user.type === "agent"}
|
disabled={checkAccess(user, ["agent"])}
|
||||||
value={
|
value={
|
||||||
assignedTo
|
assignedTo
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {Ticket, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
|
import {Ticket, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
|
||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
@@ -20,6 +21,8 @@ export default function TicketSubmission({user, page, onClose}: Props) {
|
|||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const examState = useExamStore((state) => state);
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"});
|
if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"});
|
||||||
if (subject.trim() === "")
|
if (subject.trim() === "")
|
||||||
@@ -48,6 +51,18 @@ export default function TicketSubmission({user, page, onClose}: Props) {
|
|||||||
type,
|
type,
|
||||||
reportedFrom: page,
|
reportedFrom: page,
|
||||||
description,
|
description,
|
||||||
|
examInformation:
|
||||||
|
page.includes("exam") || page.includes("exercises")
|
||||||
|
? {
|
||||||
|
exam: examState.exam?.id || "",
|
||||||
|
exams: examState.exams.map((x) => x.id),
|
||||||
|
exerciseIndex: examState.exerciseIndex,
|
||||||
|
moduleIndex: examState.moduleIndex,
|
||||||
|
partIndex: examState.partIndex,
|
||||||
|
questionIndex: examState.questionIndex,
|
||||||
|
selectedModules: examState.selectedModules,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
axios
|
axios
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ export default function Button({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"py-4 px-6 rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed cursor-pointer",
|
"py-4 px-6 rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed cursor-pointer",
|
||||||
className,
|
|
||||||
colorClassNames[color][variant],
|
colorClassNames[color][variant],
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
disabled={disabled || isLoading}>
|
disabled={disabled || isLoading}>
|
||||||
{!isLoading && children}
|
{!isLoading && children}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {ComponentProps, useEffect, useState} from "react";
|
import {ComponentProps, useEffect, useState} from "react";
|
||||||
import ReactSelect from "react-select";
|
import ReactSelect, {GroupBase, StylesConfig} from "react-select";
|
||||||
|
|
||||||
interface Option {
|
interface Option {
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
value: string;
|
value: string | null;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,9 +16,11 @@ interface Props {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onChange: (value: Option | null) => void;
|
onChange: (value: Option | null) => void;
|
||||||
isClearable?: boolean;
|
isClearable?: boolean;
|
||||||
|
styles?: StylesConfig<Option, boolean, GroupBase<Option>>;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Select({value, defaultValue, options, placeholder, disabled, onChange, isClearable}: Props) {
|
export default function Select({value, defaultValue, options, placeholder, disabled, onChange, styles, isClearable, className}: Props) {
|
||||||
const [target, setTarget] = useState<HTMLElement>();
|
const [target, setTarget] = useState<HTMLElement>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -27,33 +29,40 @@ export default function Select({value, defaultValue, options, placeholder, disab
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactSelect
|
<ReactSelect
|
||||||
className={clsx(
|
className={
|
||||||
"placeholder:text-mti-gray-cool border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none",
|
styles
|
||||||
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
? undefined
|
||||||
)}
|
: clsx(
|
||||||
|
"placeholder:text-mti-gray-cool border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none",
|
||||||
|
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
||||||
|
className,
|
||||||
|
)
|
||||||
|
}
|
||||||
options={options}
|
options={options}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange as any}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
menuPortalTarget={target}
|
menuPortalTarget={target}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
styles={{
|
styles={
|
||||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
styles || {
|
||||||
control: (styles) => ({
|
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||||
...styles,
|
control: (styles) => ({
|
||||||
paddingLeft: "4px",
|
...styles,
|
||||||
border: "none",
|
paddingLeft: "4px",
|
||||||
outline: "none",
|
border: "none",
|
||||||
":focus": {
|
|
||||||
outline: "none",
|
outline: "none",
|
||||||
},
|
":focus": {
|
||||||
}),
|
outline: "none",
|
||||||
option: (styles, state) => ({
|
},
|
||||||
...styles,
|
}),
|
||||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
option: (styles, state) => ({
|
||||||
color: state.isFocused ? "black" : styles.color,
|
...styles,
|
||||||
}),
|
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||||
}}
|
color: state.isFocused ? "black" : styles.color,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
isDisabled={disabled}
|
isDisabled={disabled}
|
||||||
isClearable={isClearable}
|
isClearable={isClearable}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,160 +1,215 @@
|
|||||||
import {User} from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import {Dialog, Transition} from "@headlessui/react";
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {Fragment} from "react";
|
import { Fragment } from "react";
|
||||||
import {BsXLg} from "react-icons/bs";
|
import { BsXLg } from "react-icons/bs";
|
||||||
|
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
path: string;
|
path: string;
|
||||||
user: User;
|
user: User;
|
||||||
disableNavigation?: boolean;
|
disableNavigation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MobileMenu({isOpen, onClose, path, user, disableNavigation}: Props) {
|
export default function MobileMenu({
|
||||||
const router = useRouter();
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
path,
|
||||||
|
user,
|
||||||
|
disableNavigation,
|
||||||
|
}: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
axios.post("/api/logout").finally(() => {
|
axios.post("/api/logout").finally(() => {
|
||||||
setTimeout(() => router.reload(), 500);
|
setTimeout(() => router.reload(), 500);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
enterFrom="opacity-0"
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="ease-in duration-200"
|
leave="ease-in duration-200"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0">
|
leaveTo="opacity-0"
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
>
|
||||||
</Transition.Child>
|
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
<div className="flex min-h-full items-center justify-center text-center">
|
<div className="flex min-h-full items-center justify-center text-center">
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
enterFrom="opacity-0 scale-95"
|
enterFrom="opacity-0 scale-95"
|
||||||
enterTo="opacity-100 scale-100"
|
enterTo="opacity-100 scale-100"
|
||||||
leave="ease-in duration-200"
|
leave="ease-in duration-200"
|
||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95">
|
leaveTo="opacity-0 scale-95"
|
||||||
<Dialog.Panel className="flex h-screen w-full transform flex-col gap-8 overflow-hidden bg-white text-left align-middle text-black shadow-xl transition-all">
|
>
|
||||||
<Dialog.Title as="header" className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden">
|
<Dialog.Panel className="flex h-screen w-full transform flex-col gap-8 overflow-hidden bg-white text-left align-middle text-black shadow-xl transition-all">
|
||||||
<Link href={disableNavigation ? "" : "/"}>
|
<Dialog.Title
|
||||||
<Image src="/logo_title.png" alt="EnCoach logo" width={69} height={69} />
|
as="header"
|
||||||
</Link>
|
className="-md:flex w-full items-center justify-between px-8 py-2 shadow-sm md:hidden"
|
||||||
<div className="cursor-pointer" onClick={onClose} tabIndex={0}>
|
>
|
||||||
<BsXLg className="text-mti-purple-light text-2xl" onClick={onClose} />
|
<Link href={disableNavigation ? "" : "/"}>
|
||||||
</div>
|
<Image
|
||||||
</Dialog.Title>
|
src="/logo_title.png"
|
||||||
<div className="flex h-full flex-col gap-6 px-8 text-lg">
|
alt="EnCoach logo"
|
||||||
<Link
|
width={69}
|
||||||
href={disableNavigation ? "" : "/"}
|
height={69}
|
||||||
className={clsx(
|
/>
|
||||||
"w-fit transition duration-300 ease-in-out",
|
</Link>
|
||||||
path === "/" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
<div
|
||||||
)}>
|
className="cursor-pointer"
|
||||||
Dashboard
|
onClick={onClose}
|
||||||
</Link>
|
tabIndex={0}
|
||||||
{(user.type === "student" || user.type === "teacher" || user.type === "developer") && (
|
>
|
||||||
<>
|
<BsXLg
|
||||||
<Link
|
className="text-mti-purple-light text-2xl"
|
||||||
href={disableNavigation ? "" : "/exam"}
|
onClick={onClose}
|
||||||
className={clsx(
|
/>
|
||||||
"w-fit transition duration-300 ease-in-out",
|
</div>
|
||||||
path === "/exam" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
</Dialog.Title>
|
||||||
)}>
|
<div className="flex h-full flex-col gap-6 px-8 text-lg">
|
||||||
Exams
|
<Link
|
||||||
</Link>
|
href={disableNavigation ? "" : "/"}
|
||||||
<Link
|
className={clsx(
|
||||||
href={disableNavigation ? "" : "/exercises"}
|
"w-fit transition duration-300 ease-in-out",
|
||||||
className={clsx(
|
path === "/" &&
|
||||||
"w-fit transition duration-300 ease-in-out",
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
path === "/exercises" &&
|
)}
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
>
|
||||||
)}>
|
Dashboard
|
||||||
Exercises
|
</Link>
|
||||||
</Link>
|
{checkAccess(user, ["student", "teacher", "developer"]) && (
|
||||||
</>
|
<>
|
||||||
)}
|
<Link
|
||||||
<Link
|
href={disableNavigation ? "" : "/exam"}
|
||||||
href={disableNavigation ? "" : "/stats"}
|
className={clsx(
|
||||||
className={clsx(
|
"w-fit transition duration-300 ease-in-out",
|
||||||
"w-fit transition duration-300 ease-in-out",
|
path === "/exam" &&
|
||||||
path === "/stats" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
)}>
|
)}
|
||||||
Stats
|
>
|
||||||
</Link>
|
Exams
|
||||||
<Link
|
</Link>
|
||||||
href={disableNavigation ? "" : "/record"}
|
<Link
|
||||||
className={clsx(
|
href={disableNavigation ? "" : "/exercises"}
|
||||||
"w-fit transition duration-300 ease-in-out",
|
className={clsx(
|
||||||
path === "/record" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
)}>
|
path === "/exercises" &&
|
||||||
Record
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
</Link>
|
)}
|
||||||
{["admin", "developer", "agent", "corporate"].includes(user.type) && (
|
>
|
||||||
<Link
|
Exercises
|
||||||
href={disableNavigation ? "" : "/payment-record"}
|
</Link>
|
||||||
className={clsx(
|
</>
|
||||||
"w-fit transition duration-300 ease-in-out",
|
)}
|
||||||
path === "/payment-record" &&
|
<Link
|
||||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
href={disableNavigation ? "" : "/stats"}
|
||||||
)}>
|
className={clsx(
|
||||||
Payment Record
|
"w-fit transition duration-300 ease-in-out",
|
||||||
</Link>
|
path === "/stats" &&
|
||||||
)}
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
{["admin", "developer", "corporate", "teacher"].includes(user.type) && (
|
)}
|
||||||
<Link
|
>
|
||||||
href={disableNavigation ? "" : "/settings"}
|
Stats
|
||||||
className={clsx(
|
</Link>
|
||||||
"w-fit transition duration-300 ease-in-out",
|
<Link
|
||||||
path === "/settings" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
href={disableNavigation ? "" : "/record"}
|
||||||
)}>
|
className={clsx(
|
||||||
Settings
|
"w-fit transition duration-300 ease-in-out",
|
||||||
</Link>
|
path === "/record" &&
|
||||||
)}
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
{["admin", "developer", "agent"].includes(user.type) && (
|
)}
|
||||||
<Link
|
>
|
||||||
href={disableNavigation ? "" : "/tickets"}
|
Record
|
||||||
className={clsx(
|
</Link>
|
||||||
"w-fit transition duration-300 ease-in-out",
|
{checkAccess(user, [
|
||||||
path === "/tickets" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
"admin",
|
||||||
)}>
|
"developer",
|
||||||
Tickets
|
"agent",
|
||||||
</Link>
|
"corporate",
|
||||||
)}
|
"mastercorporate",
|
||||||
<Link
|
]) && (
|
||||||
href={disableNavigation ? "" : "/profile"}
|
<Link
|
||||||
className={clsx(
|
href={disableNavigation ? "" : "/payment-record"}
|
||||||
"w-fit transition duration-300 ease-in-out",
|
className={clsx(
|
||||||
path === "/profile" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
"w-fit transition duration-300 ease-in-out",
|
||||||
)}>
|
path === "/payment-record" &&
|
||||||
Profile
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
</Link>
|
)}
|
||||||
|
>
|
||||||
|
Payment Record
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{checkAccess(user, [
|
||||||
|
"admin",
|
||||||
|
"developer",
|
||||||
|
"corporate",
|
||||||
|
"teacher",
|
||||||
|
"mastercorporate",
|
||||||
|
]) && (
|
||||||
|
<Link
|
||||||
|
href={disableNavigation ? "" : "/settings"}
|
||||||
|
className={clsx(
|
||||||
|
"w-fit transition duration-300 ease-in-out",
|
||||||
|
path === "/settings" &&
|
||||||
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{checkAccess(user, ["admin", "developer", "agent"]) && (
|
||||||
|
<Link
|
||||||
|
href={disableNavigation ? "" : "/tickets"}
|
||||||
|
className={clsx(
|
||||||
|
"w-fit transition duration-300 ease-in-out",
|
||||||
|
path === "/tickets" &&
|
||||||
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Tickets
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={disableNavigation ? "" : "/profile"}
|
||||||
|
className={clsx(
|
||||||
|
"w-fit transition duration-300 ease-in-out",
|
||||||
|
path === "/profile" &&
|
||||||
|
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Profile
|
||||||
|
</Link>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className={clsx("w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out")}
|
className={clsx(
|
||||||
onClick={logout}>
|
"w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out"
|
||||||
Logout
|
)}
|
||||||
</span>
|
onClick={logout}
|
||||||
</div>
|
>
|
||||||
</Dialog.Panel>
|
Logout
|
||||||
</Transition.Child>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Transition.Child>
|
||||||
</Transition>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import {Dialog, Transition} from "@headlessui/react";
|
import {Dialog, Transition} from "@headlessui/react";
|
||||||
|
import clsx from "clsx";
|
||||||
import {Fragment, ReactElement} from "react";
|
import {Fragment, ReactElement} from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
className?: string;
|
||||||
children?: ReactElement;
|
children?: ReactElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Modal({isOpen, title, onClose, children}: Props) {
|
export default function Modal({isOpen, title, className, onClose, children}: Props) {
|
||||||
return (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
<Dialog as="div" className="relative z-[200]" onClose={onClose}>
|
<Dialog as="div" className="relative z-[200]" onClose={onClose}>
|
||||||
@@ -33,7 +35,11 @@ export default function Modal({isOpen, title, onClose, children}: Props) {
|
|||||||
leave="ease-in duration-200"
|
leave="ease-in duration-200"
|
||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95">
|
leaveTo="opacity-0 scale-95">
|
||||||
<Dialog.Panel className="w-full max-w-6xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
|
<Dialog.Panel
|
||||||
|
className={clsx(
|
||||||
|
"w-full max-w-6xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all",
|
||||||
|
className,
|
||||||
|
)}>
|
||||||
{title && (
|
{title && (
|
||||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -1,116 +1,219 @@
|
|||||||
import {User} from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import FocusLayer from "@/components/FocusLayer";
|
import FocusLayer from "@/components/FocusLayer";
|
||||||
import {preventNavigation} from "@/utils/navigation.disabled";
|
import { preventNavigation } from "@/utils/navigation.disabled";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {BsList, BsQuestionCircle, BsQuestionCircleFill} from "react-icons/bs";
|
import { BsList, BsQuestionCircle, BsQuestionCircleFill } from "react-icons/bs";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import MobileMenu from "./MobileMenu";
|
import MobileMenu from "./MobileMenu";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {Type} from "@/interfaces/user";
|
import { Type } from "@/interfaces/user";
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import {isUserFromCorporate} from "@/utils/groups";
|
import { isUserFromCorporate } from "@/utils/groups";
|
||||||
import Button from "./Low/Button";
|
import Button from "./Low/Button";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
import Input from "./Low/Input";
|
import Input from "./Low/Input";
|
||||||
import TicketSubmission from "./High/TicketSubmission";
|
import TicketSubmission from "./High/TicketSubmission";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import Badge from "./Low/Badge";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BsArrowRepeat,
|
||||||
|
BsBook,
|
||||||
|
BsCheck,
|
||||||
|
BsCheckCircle,
|
||||||
|
BsClipboard,
|
||||||
|
BsHeadphones,
|
||||||
|
BsMegaphone,
|
||||||
|
BsPen,
|
||||||
|
BsXCircle,
|
||||||
|
} from "react-icons/bs";
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
navDisabled?: boolean;
|
navDisabled?: boolean;
|
||||||
focusMode?: boolean;
|
focusMode?: boolean;
|
||||||
onFocusLayerMouseEnter?: () => void;
|
onFocusLayerMouseEnter?: () => void;
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
export default function Navbar({
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
user,
|
||||||
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
path,
|
||||||
const [isTicketOpen, setIsTicketOpen] = useState(false);
|
navDisabled = false,
|
||||||
|
focusMode = false,
|
||||||
|
onFocusLayerMouseEnter,
|
||||||
|
}: Props) {
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
||||||
|
const [isTicketOpen, setIsTicketOpen] = useState(false);
|
||||||
|
|
||||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
const router = useRouter();
|
||||||
|
|
||||||
const expirationDateColor = (date: Date) => {
|
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||||
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";
|
const expirationDateColor = (date: Date) => {
|
||||||
if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light";
|
const momentDate = moment(date);
|
||||||
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light";
|
const today = moment(new Date());
|
||||||
};
|
|
||||||
|
|
||||||
const showExpirationDate = () => {
|
if (today.add(1, "days").isAfter(momentDate))
|
||||||
if (!user.subscriptionExpirationDate) return false;
|
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 momentDate = moment(user.subscriptionExpirationDate);
|
const showExpirationDate = () => {
|
||||||
const today = moment(new Date());
|
if (!user.subscriptionExpirationDate) return false;
|
||||||
|
|
||||||
return today.add(7, "days").isAfter(momentDate);
|
const momentDate = moment(user.subscriptionExpirationDate);
|
||||||
};
|
const today = moment(new Date());
|
||||||
|
|
||||||
useEffect(() => {
|
return today.add(7, "days").isAfter(momentDate);
|
||||||
if (user.type !== "student" && user.type !== "teacher") return setDisablePaymentPage(false);
|
};
|
||||||
isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result));
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<>
|
if (user.type !== "student" && user.type !== "teacher")
|
||||||
<Modal isOpen={isTicketOpen} onClose={() => setIsTicketOpen(false)} title="Submit a ticket">
|
return setDisablePaymentPage(false);
|
||||||
<TicketSubmission user={user} page={window.location.href} onClose={() => setIsTicketOpen(false)} />
|
isUserFromCorporate(user.id).then((result) =>
|
||||||
</Modal>
|
setDisablePaymentPage(result)
|
||||||
|
);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
{user && (
|
const badges = [
|
||||||
<MobileMenu disableNavigation={disableNavigation} path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />
|
{
|
||||||
)}
|
module: "reading",
|
||||||
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
|
icon: () => <BsBook className="h-4 w-4 text-white" />,
|
||||||
<Link href={disableNavigation ? "" : "/"} className=" flex items-center gap-8 md:px-8">
|
achieved: user.levels.reading >= user.desiredLevels.reading,
|
||||||
<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">
|
|
||||||
{/* 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",
|
|
||||||
)}
|
|
||||||
data-tip="Submit a help/feedback ticket"
|
|
||||||
onClick={() => setIsTicketOpen(true)}>
|
|
||||||
<BsQuestionCircleFill />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showExpirationDate() && (
|
{
|
||||||
<Link
|
module: "listening",
|
||||||
href={disablePaymentPage ? "/payment" : ""}
|
icon: () => <BsHeadphones className="h-4 w-4 text-white" />,
|
||||||
data-tip="Expiry date"
|
achieved: user.levels.listening >= user.desiredLevels.listening,
|
||||||
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",
|
module: "writing",
|
||||||
!user.subscriptionExpirationDate
|
icon: () => <BsPen className="h-4 w-4 text-white" />,
|
||||||
? "bg-mti-green-ultralight border-mti-green-light"
|
achieved: user.levels.writing >= user.desiredLevels.writing,
|
||||||
: expirationDateColor(user.subscriptionExpirationDate),
|
},
|
||||||
"border-mti-gray-platinum bg-white",
|
{
|
||||||
)}>
|
module: "speaking",
|
||||||
{!user.subscriptionExpirationDate && "Unlimited"}
|
icon: () => <BsMegaphone className="h-4 w-4 text-white" />,
|
||||||
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
achieved: user.levels.speaking >= user.desiredLevels.speaking,
|
||||||
</Link>
|
},
|
||||||
)}
|
{
|
||||||
<Link href={disableNavigation ? "" : "/profile"} className="-md:hidden flex items-center justify-end gap-6">
|
module: "level",
|
||||||
<img src={user.profilePicture} alt={user.name} className="h-10 w-10 rounded-full object-cover" />
|
icon: () => <BsClipboard className="h-4 w-4 text-white" />,
|
||||||
<span className="-md:hidden text-right">
|
achieved: user.levels.level >= user.desiredLevels.level,
|
||||||
{user.type === "corporate" ? `${user.corporateInformation?.companyInformation.name} |` : ""} {user.name} |{" "}
|
},
|
||||||
{USER_TYPE_LABELS[user.type]}
|
];
|
||||||
</span>
|
|
||||||
</Link>
|
return (
|
||||||
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
|
<>
|
||||||
<BsList className="text-mti-purple-light h-8 w-8" />
|
<Modal
|
||||||
</div>
|
isOpen={isTicketOpen}
|
||||||
</div>
|
onClose={() => setIsTicketOpen(false)}
|
||||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
title="Submit a ticket"
|
||||||
</header>
|
>
|
||||||
</>
|
<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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
import { DurationUnit } from "@/interfaces/paypal";
|
|
||||||
import {
|
|
||||||
CreateOrderActions,
|
|
||||||
CreateOrderData,
|
|
||||||
OnApproveActions,
|
|
||||||
OnApproveData,
|
|
||||||
OnCancelledActions,
|
|
||||||
OrderResponseBody,
|
|
||||||
} from "@paypal/paypal-js";
|
|
||||||
import {
|
|
||||||
PayPalButtons,
|
|
||||||
PayPalScriptProvider,
|
|
||||||
usePayPalScriptReducer,
|
|
||||||
} from "@paypal/react-paypal-js";
|
|
||||||
import axios from "axios";
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { toast } from "react-toastify";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
clientID: string;
|
|
||||||
currency: string;
|
|
||||||
price: number;
|
|
||||||
duration: number;
|
|
||||||
duration_unit: DurationUnit;
|
|
||||||
loadScript?: boolean;
|
|
||||||
setIsLoading: (isLoading: boolean) => void;
|
|
||||||
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
|
|
||||||
trackingId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PayPalPayment({
|
|
||||||
clientID,
|
|
||||||
price,
|
|
||||||
currency,
|
|
||||||
duration,
|
|
||||||
duration_unit,
|
|
||||||
loadScript,
|
|
||||||
setIsLoading,
|
|
||||||
onSuccess,
|
|
||||||
trackingId,
|
|
||||||
}: Props) {
|
|
||||||
const createOrder = async (
|
|
||||||
data: CreateOrderData,
|
|
||||||
actions: CreateOrderActions
|
|
||||||
): Promise<string> => {
|
|
||||||
if (!trackingId) {
|
|
||||||
throw new Error("trackingId is not set");
|
|
||||||
}
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
return axios
|
|
||||||
.post<OrderResponseBody>("/api/paypal", {
|
|
||||||
currencyCode: currency,
|
|
||||||
price,
|
|
||||||
trackingId,
|
|
||||||
})
|
|
||||||
.then((response) => response.data)
|
|
||||||
.then((data) => data.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onApprove = async (data: OnApproveData, actions: OnApproveActions) => {
|
|
||||||
if (!trackingId) {
|
|
||||||
throw new Error("trackingId is not set");
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = await axios.post<{ ok: boolean; reason?: string }>(
|
|
||||||
"/api/paypal/approve",
|
|
||||||
{ id: data.orderID, duration, duration_unit, trackingId }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (request.status !== 200) {
|
|
||||||
toast.error("Something went wrong, please try again later");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success("Your account has been credited more time!");
|
|
||||||
return onSuccess(duration, duration_unit);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onError = async (data: Record<string, unknown>) => {
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCancel = async (
|
|
||||||
data: Record<string, unknown>,
|
|
||||||
actions: OnCancelledActions
|
|
||||||
) => {
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (trackingId) {
|
|
||||||
return loadScript ? (
|
|
||||||
<PayPalScriptProvider
|
|
||||||
options={{
|
|
||||||
clientId: clientID,
|
|
||||||
currency,
|
|
||||||
intent: "capture",
|
|
||||||
commit: true,
|
|
||||||
vault: true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PayPalButtons
|
|
||||||
className="w-full"
|
|
||||||
style={{ layout: "vertical" }}
|
|
||||||
createOrder={createOrder}
|
|
||||||
onApprove={onApprove}
|
|
||||||
onCancel={onCancel}
|
|
||||||
onError={onError}
|
|
||||||
/>
|
|
||||||
</PayPalScriptProvider>
|
|
||||||
) : (
|
|
||||||
<PayPalButtons
|
|
||||||
className="w-full"
|
|
||||||
style={{ layout: "vertical" }}
|
|
||||||
createOrder={createOrder}
|
|
||||||
onApprove={onApprove}
|
|
||||||
onCancel={onCancel}
|
|
||||||
onError={onError}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
77
src/components/PaymobPayment.tsx
Normal file
77
src/components/PaymobPayment.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import {PaymentIntention} from "@/interfaces/paymob";
|
||||||
|
import {DurationUnit} from "@/interfaces/paypal";
|
||||||
|
import {User} from "@/interfaces/user";
|
||||||
|
import axios from "axios";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import {useState} from "react";
|
||||||
|
import Button from "./Low/Button";
|
||||||
|
import Input from "./Low/Input";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
currency: string;
|
||||||
|
price: number;
|
||||||
|
setIsPaymentLoading: (v: boolean) => void;
|
||||||
|
duration: number;
|
||||||
|
duration_unit: DurationUnit;
|
||||||
|
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PaymobPayment({user, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess}: Props) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleCardPayment = async () => {
|
||||||
|
try {
|
||||||
|
setIsPaymentLoading(true);
|
||||||
|
|
||||||
|
const paymentIntention: PaymentIntention = {
|
||||||
|
amount: price * 1000,
|
||||||
|
currency: "OMR",
|
||||||
|
items: [],
|
||||||
|
payment_methods: [],
|
||||||
|
customer: {
|
||||||
|
email: user.email,
|
||||||
|
first_name: user.name.split(" ")[0],
|
||||||
|
last_name: [...user.name.split(" ")].pop() || "N/A",
|
||||||
|
extras: {
|
||||||
|
re: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
billing_data: {
|
||||||
|
apartment: "N/A",
|
||||||
|
building: "N/A",
|
||||||
|
country: user.demographicInformation?.country || "N/A",
|
||||||
|
email: user.email,
|
||||||
|
first_name: user.name.split(" ")[0],
|
||||||
|
last_name: [...user.name.split(" ")].pop() || "N/A",
|
||||||
|
floor: "N/A",
|
||||||
|
phone_number: user.demographicInformation?.phone || "N/A",
|
||||||
|
state: "N/A",
|
||||||
|
street: "N/A",
|
||||||
|
},
|
||||||
|
extras: {
|
||||||
|
userID: user.id,
|
||||||
|
duration,
|
||||||
|
duration_unit,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await axios.post<{iframeURL: string}>(`/api/paymob`, paymentIntention);
|
||||||
|
|
||||||
|
router.push(response.data.iframeURL);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error starting card payment process:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button isLoading={isLoading} onClick={handleCardPayment}>
|
||||||
|
Select
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/components/PermissionList.tsx
Normal file
62
src/components/PermissionList.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import {Permission} from "@/interfaces/permissions";
|
||||||
|
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {convertCamelCaseToReadable} from "@/utils/string";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
permissions: Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<Permission>();
|
||||||
|
|
||||||
|
const defaultColumns = [
|
||||||
|
columnHelper.accessor("type", {
|
||||||
|
header: () => <span>Type</span>,
|
||||||
|
cell: ({row, getValue}) => (
|
||||||
|
<Link
|
||||||
|
href={`/permissions/${row.original.id}`}
|
||||||
|
key={row.id}
|
||||||
|
className="underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer">
|
||||||
|
{convertCamelCaseToReadable(getValue() as string)}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function PermissionList({permissions}: Props) {
|
||||||
|
const table = useReactTable({
|
||||||
|
data: permissions,
|
||||||
|
columns: defaultColumns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="w-full flex flex-col gap-2">
|
||||||
|
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||||
|
<thead>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<th className="py-4 px-4 text-left" key={header.id}>
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody className="px-2">
|
||||||
|
{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())}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/components/RadialProgressBar.tsx
Normal file
64
src/components/RadialProgressBar.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
|
||||||
|
interface RadialProgressBarProps {
|
||||||
|
percentage: number;
|
||||||
|
text: string;
|
||||||
|
color: string;
|
||||||
|
spanClassName?: string;
|
||||||
|
size?: number;
|
||||||
|
strokeWidth?: number;
|
||||||
|
strokeOpacity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// https://gist.github.com/eYinka/873be69fae3ef27b103681b8a9f5e379 Omarmarei's answer
|
||||||
|
const RadialProgressBar: React.FC<RadialProgressBarProps> = ({
|
||||||
|
percentage,
|
||||||
|
text,
|
||||||
|
color,
|
||||||
|
spanClassName = "",
|
||||||
|
size = 100,
|
||||||
|
strokeWidth = 10,
|
||||||
|
strokeOpacity = 0.5
|
||||||
|
}) => {
|
||||||
|
const radius = (size - strokeWidth) / 2;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const offset = circumference - (percentage / 100) * circumference;
|
||||||
|
return (
|
||||||
|
<div className='relative flex items-center justify-center' style={{ width: size, height: size}}>
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox={`0 0 ${size} ${size}`
|
||||||
|
}
|
||||||
|
className="circular-progress-bar"
|
||||||
|
>
|
||||||
|
<circle className="circle-bg" stroke="#e6e6e6" strokeWidth={strokeWidth}
|
||||||
|
fill="none"
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
strokeOpacity={strokeOpacity}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
className="circle-progress"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="none"
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||||
|
strokeOpacity={strokeOpacity}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className={clsx('absolute', spanClassName)}>{text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default RadialProgressBar;
|
||||||
48
src/components/SegmentedProgressBar.tsx
Normal file
48
src/components/SegmentedProgressBar.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
interface Segment {
|
||||||
|
percentage: number;
|
||||||
|
subtitle: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
interface SegmentedProgressBarProps {
|
||||||
|
segments: Segment[];
|
||||||
|
height?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
const SegmentedProgressBar: React.FC<SegmentedProgressBarProps> = ({ segments, height=15, className="" }) => {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<div className="relative flex rounded-full overflow-hidden bg-gray-200" style={{height: `${height}px`}}>
|
||||||
|
{segments.map((segment, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={clsx(
|
||||||
|
'h-full opacity-50',
|
||||||
|
'transition-all duration-500 ease-out',
|
||||||
|
`bg-${segment.color}`,
|
||||||
|
{
|
||||||
|
'rounded-l-full': index === 0,
|
||||||
|
'rounded-r-full': index === segments.length - 1,
|
||||||
|
'rounded-none': index !== 0 && index !== segments.length - 1
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
style={{width: `${segment.percentage}%`}}
|
||||||
|
></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex text-sm justify-between">
|
||||||
|
{segments.map((segment, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex flex-col text-center w-fit"
|
||||||
|
>
|
||||||
|
<span className={clsx('font-semibold',`text-${segment.color}`)}>{segment.subtitle}</span>
|
||||||
|
<span>{`${segment.percentage}%`}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default SegmentedProgressBar;
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
BsCloudFill,
|
BsCloudFill,
|
||||||
BsCurrencyDollar,
|
BsCurrencyDollar,
|
||||||
BsClipboardData,
|
BsClipboardData,
|
||||||
|
BsFileLock,
|
||||||
} from "react-icons/bs";
|
} from "react-icons/bs";
|
||||||
import {RiLogoutBoxFill} from "react-icons/ri";
|
import {RiLogoutBoxFill} from "react-icons/ri";
|
||||||
import {SlPencil} from "react-icons/sl";
|
import {SlPencil} from "react-icons/sl";
|
||||||
@@ -23,16 +24,16 @@ import FocusLayer from "@/components/FocusLayer";
|
|||||||
import {preventNavigation} from "@/utils/navigation.disabled";
|
import {preventNavigation} from "@/utils/navigation.disabled";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import usePreferencesStore from "@/stores/preferencesStore";
|
import usePreferencesStore from "@/stores/preferencesStore";
|
||||||
import {Type} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import useTicketsListener from "@/hooks/useTicketsListener";
|
import useTicketsListener from "@/hooks/useTicketsListener";
|
||||||
|
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
||||||
interface Props {
|
interface Props {
|
||||||
path: string;
|
path: string;
|
||||||
navDisabled?: boolean;
|
navDisabled?: boolean;
|
||||||
focusMode?: boolean;
|
focusMode?: boolean;
|
||||||
onFocusLayerMouseEnter?: () => void;
|
onFocusLayerMouseEnter?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
userType?: Type;
|
user: User;
|
||||||
userId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavProps {
|
interface NavProps {
|
||||||
@@ -72,14 +73,12 @@ const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false,
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Sidebar({path, navDisabled = false, focusMode = false, userType, onFocusLayerMouseEnter, className, userId}: Props) {
|
export default function Sidebar({path, navDisabled = false, focusMode = false, user, onFocusLayerMouseEnter, className}: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
|
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
|
||||||
|
|
||||||
const {totalAssignedTickets} = useTicketsListener(userId);
|
const {totalAssignedTickets} = useTicketsListener(user.id);
|
||||||
|
|
||||||
useEffect(() => console.log(totalAssignedTickets), [totalAssignedTickets]);
|
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
axios.post("/api/logout").finally(() => {
|
axios.post("/api/logout").finally(() => {
|
||||||
@@ -98,29 +97,19 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
)}>
|
)}>
|
||||||
<div className="-xl:hidden flex-col gap-3 xl:flex">
|
<div className="-xl:hidden flex-col gap-3 xl:flex">
|
||||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
|
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
|
||||||
{(userType === "student" || userType === "teacher" || userType === "developer") && (
|
{checkAccess(user, ["student", "teacher", "developer"], "viewExams") && (
|
||||||
<>
|
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={isMinimized} />
|
||||||
<Nav
|
|
||||||
disabled={disableNavigation}
|
|
||||||
Icon={BsFileEarmarkText}
|
|
||||||
label="Exams"
|
|
||||||
path={path}
|
|
||||||
keyPath="/exam"
|
|
||||||
isMinimized={isMinimized}
|
|
||||||
/>
|
|
||||||
<Nav
|
|
||||||
disabled={disableNavigation}
|
|
||||||
Icon={BsPencil}
|
|
||||||
label="Exercises"
|
|
||||||
path={path}
|
|
||||||
keyPath="/exercises"
|
|
||||||
isMinimized={isMinimized}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
|
{checkAccess(user, ["student", "teacher", "developer"], "viewExercises") && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
|
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={isMinimized} />
|
||||||
{["admin", "developer", "agent", "corporate"].includes(userType || "") && (
|
)}
|
||||||
|
{checkAccess(user, getTypesOfUser(["agent"]), "viewStats") && (
|
||||||
|
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
|
||||||
|
)}
|
||||||
|
{checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && (
|
||||||
|
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
|
||||||
|
)}
|
||||||
|
{checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"], "viewPaymentRecords") && (
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
Icon={BsCurrencyDollar}
|
Icon={BsCurrencyDollar}
|
||||||
@@ -130,7 +119,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
isMinimized={isMinimized}
|
isMinimized={isMinimized}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{["admin", "developer", "corporate", "teacher"].includes(userType || "") && (
|
{checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]) && (
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
Icon={BsShieldFill}
|
Icon={BsShieldFill}
|
||||||
@@ -140,7 +129,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
isMinimized={isMinimized}
|
isMinimized={isMinimized}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{["admin", "developer", "agent"].includes(userType || "") && (
|
{checkAccess(user, ["admin", "developer", "agent"], "viewTickets") && (
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
Icon={BsClipboardData}
|
Icon={BsClipboardData}
|
||||||
@@ -151,28 +140,62 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
badge={totalAssignedTickets}
|
badge={totalAssignedTickets}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{userType === "developer" && (
|
{checkAccess(user, ["developer", "admin"]) && (
|
||||||
<Nav
|
<>
|
||||||
disabled={disableNavigation}
|
<Nav
|
||||||
Icon={BsCloudFill}
|
disabled={disableNavigation}
|
||||||
label="Generation"
|
Icon={BsCloudFill}
|
||||||
path={path}
|
label="Generation"
|
||||||
keyPath="/generation"
|
path={path}
|
||||||
isMinimized={isMinimized}
|
keyPath="/generation"
|
||||||
/>
|
isMinimized={isMinimized}
|
||||||
|
/>
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsFileLock}
|
||||||
|
label="Permissions"
|
||||||
|
path={path}
|
||||||
|
keyPath="/permissions"
|
||||||
|
isMinimized={isMinimized}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
||||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
|
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
|
||||||
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
|
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
|
||||||
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
|
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
|
||||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
|
{checkAccess(user, getTypesOfUser(["agent"]), "viewStats") && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
|
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
|
||||||
{userType !== "student" && (
|
)}
|
||||||
|
{checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && (
|
||||||
|
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
|
||||||
|
)}
|
||||||
|
{checkAccess(user, getTypesOfUser(["student"])) && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
|
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
|
||||||
)}
|
)}
|
||||||
{userType === "developer" && (
|
{checkAccess(user, getTypesOfUser(["student"])) && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsCloudFill} label="Generation" path={path} keyPath="/generation" isMinimized={true} />
|
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Permissions" path={path} keyPath="/permissions" isMinimized={true} />
|
||||||
|
)}
|
||||||
|
{checkAccess(user, ["developer"]) && (
|
||||||
|
<>
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsCloudFill}
|
||||||
|
label="Generation"
|
||||||
|
path={path}
|
||||||
|
keyPath="/generation"
|
||||||
|
isMinimized={true}
|
||||||
|
/>
|
||||||
|
<Nav
|
||||||
|
disabled={disableNavigation}
|
||||||
|
Icon={BsFileLock}
|
||||||
|
label="Permissions"
|
||||||
|
path={path}
|
||||||
|
keyPath="/permissions"
|
||||||
|
isMinimized={true}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,30 @@ import {CommonProps} from ".";
|
|||||||
import {Fragment} from "react";
|
import {Fragment} from "react";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
|
|
||||||
export default function FillBlanksSolutions({id, type, prompt, solutions, text, userSolutions, onNext, onBack}: FillBlanksExercise & CommonProps) {
|
export default function FillBlanksSolutions({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
prompt,
|
||||||
|
solutions,
|
||||||
|
words,
|
||||||
|
text,
|
||||||
|
userSolutions,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
}: FillBlanksExercise & CommonProps) {
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||||
const correct = userSolutions.filter(
|
const correct = userSolutions.filter((x) => {
|
||||||
(x) => solutions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false,
|
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution.toLowerCase();
|
||||||
).length;
|
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 - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||||
|
|
||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
@@ -35,7 +53,14 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userSolution.solution === solution.solution) {
|
const userSolutionWord = words.find((w) =>
|
||||||
|
typeof w === "string"
|
||||||
|
? w.toLowerCase() === userSolution.solution.toLowerCase()
|
||||||
|
: w.letter.toLowerCase() === userSolution.solution.toLowerCase(),
|
||||||
|
);
|
||||||
|
const userSolutionText = typeof userSolutionWord === "string" ? userSolutionWord : userSolutionWord?.word;
|
||||||
|
|
||||||
|
if (userSolutionText === solution.solution) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -47,7 +72,7 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userSolution.solution !== solution.solution) {
|
if (userSolutionText !== solution.solution) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -55,7 +80,7 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
|
|||||||
"rounded-full hover:text-white hover:bg-mti-rose transition duration-300 ease-in-out my-1 mr-1",
|
"rounded-full hover:text-white hover:bg-mti-rose transition duration-300 ease-in-out my-1 mr-1",
|
||||||
userSolution && "px-5 py-2 text-center text-white bg-mti-rose-light",
|
userSolution && "px-5 py-2 text-center text-white bg-mti-rose-light",
|
||||||
)}>
|
)}>
|
||||||
{userSolution.solution}
|
{userSolutionText}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -75,7 +100,7 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
|
|||||||
@@ -132,11 +132,16 @@ export default function InteractiveSpeaking({
|
|||||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<div className="flex gap-4 px-1">
|
<div className="flex gap-4 px-1">
|
||||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
||||||
<div className="bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2" key={key}>
|
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
||||||
{key}: Level {userSolutions[0].evaluation!.task_response[key]}
|
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||||
</div>
|
|
||||||
))}
|
return (
|
||||||
|
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2")} key={key}>
|
||||||
|
{key}: Level {grade}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{userSolutions[0].evaluation &&
|
{userSolutions[0].evaluation &&
|
||||||
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length === 3 ? (
|
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length === 3 ? (
|
||||||
@@ -186,6 +191,17 @@ export default function InteractiveSpeaking({
|
|||||||
}>
|
}>
|
||||||
Recommended Answer (Prompt 3)
|
Recommended Answer (Prompt 3)
|
||||||
</Tab>
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
className={({selected}) =>
|
||||||
|
clsx(
|
||||||
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||||
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
Global Overview
|
||||||
|
</Tab>
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels>
|
<Tab.Panels>
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
@@ -193,19 +209,36 @@ export default function InteractiveSpeaking({
|
|||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||||
{userSolutions[0].evaluation!.perfect_answer_1!.replaceAll(/\s{2,}/g, "\n\n")}
|
{userSolutions[0].evaluation!.perfect_answer_1!.answer.replaceAll(/\s{2,}/g, "\n\n")}
|
||||||
</span>
|
</span>
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||||
{userSolutions[0].evaluation!.perfect_answer_2!.replaceAll(/\s{2,}/g, "\n\n")}
|
{userSolutions[0].evaluation!.perfect_answer_2!.answer.replaceAll(/\s{2,}/g, "\n\n")}
|
||||||
</span>
|
</span>
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||||
{userSolutions[0].evaluation!.perfect_answer_3!.replaceAll(/\s{2,}/g, "\n\n")}
|
{userSolutions[0].evaluation!.perfect_answer_3!.answer.replaceAll(/\s{2,}/g, "\n\n")}
|
||||||
</span>
|
</span>
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
||||||
|
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
||||||
|
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex flex-col gap-2">
|
||||||
|
<span className={"font-semibold"}>
|
||||||
|
{key}: Level {grade}
|
||||||
|
</span>
|
||||||
|
{typeof taskResponse !== "number" && <span>{taskResponse.comment}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Tab.Panel>
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {MatchSentencesExercise} from "@/interfaces/exam";
|
import {MatchSentenceExerciseSentence, MatchSentencesExercise} from "@/interfaces/exam";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import LineTo from "react-lineto";
|
import LineTo from "react-lineto";
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
@@ -9,6 +9,48 @@ import {Fragment} from "react";
|
|||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import Xarrow from "react-xarrows";
|
import Xarrow from "react-xarrows";
|
||||||
|
|
||||||
|
function QuestionSolutionArea({
|
||||||
|
question,
|
||||||
|
userSolution,
|
||||||
|
}: {
|
||||||
|
question: MatchSentenceExerciseSentence;
|
||||||
|
userSolution?: {question: string; option: string};
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="flex items-center gap-3 cursor-pointer col-span-2">
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
"text-white w-8 h-8 rounded-full z-10",
|
||||||
|
!userSolution
|
||||||
|
? "bg-mti-gray-davy"
|
||||||
|
: userSolution.option.toString() === question.solution.toString()
|
||||||
|
? "bg-mti-purple"
|
||||||
|
: "bg-mti-rose",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
)}>
|
||||||
|
{question.id}
|
||||||
|
</button>
|
||||||
|
<span>{question.sentence}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"w-56 h-10 border rounded-xl items-center justify-center flex gap-3 px-2",
|
||||||
|
!userSolution
|
||||||
|
? "border-mti-gray-davy"
|
||||||
|
: userSolution.option.toString() === question.solution.toString()
|
||||||
|
? "border-mti-purple"
|
||||||
|
: "border-mti-rose",
|
||||||
|
)}>
|
||||||
|
<span className="line-through">
|
||||||
|
{userSolution && userSolution?.option.toString() !== question.solution.toString() && `Paragraph ${userSolution.option}`}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">Paragraph {question.solution}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function MatchSentencesSolutions({
|
export default function MatchSentencesSolutions({
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
@@ -31,7 +73,7 @@ export default function MatchSentencesSolutions({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
@@ -40,57 +82,18 @@ export default function MatchSentencesSolutions({
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
<div className="flex flex-col gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{sentences.map(({sentence, id, solution}) => (
|
{sentences.map((question) => (
|
||||||
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
|
<QuestionSolutionArea
|
||||||
<span>{sentence} </span>
|
question={question}
|
||||||
<button
|
userSolution={userSolutions.find((x) => x.question.toString() === question.id.toString())}
|
||||||
id={id}
|
key={`question_${question.id}`}
|
||||||
className={clsx(
|
|
||||||
"w-8 h-8 rounded-full z-10 text-white",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
!userSolutions.find((x) => x.question.toString() === id.toString()) && "!bg-mti-gray-davy",
|
|
||||||
userSolutions.find((x) => x.question.toString() === id.toString())?.option === solution && "bg-mti-purple",
|
|
||||||
userSolutions.find((x) => x.question.toString() === id.toString())?.option !== solution && "bg-mti-rose",
|
|
||||||
)}>
|
|
||||||
{id}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{options.map(({sentence, id}) => (
|
|
||||||
<div key={`answer_${id}`} className={clsx("flex items-center justify-start gap-2 cursor-pointer")}>
|
|
||||||
<button
|
|
||||||
id={id}
|
|
||||||
className={clsx(
|
|
||||||
"bg-mti-purple-ultralight text-mti-purple hover:text-white hover:bg-mti-purple w-8 h-8 rounded-full z-10",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
)}>
|
|
||||||
{id}
|
|
||||||
</button>
|
|
||||||
<span>{sentence}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{userSolutions &&
|
|
||||||
sentences.map((sentence, index) => (
|
|
||||||
<Xarrow
|
|
||||||
key={index}
|
|
||||||
start={sentence.id}
|
|
||||||
end={sentence.solution}
|
|
||||||
lineColor={
|
|
||||||
!userSolutions.find((x) => x.question === sentence.id)
|
|
||||||
? "#CC5454"
|
|
||||||
: userSolutions.find((x) => x.question === sentence.id)?.option === sentence.solution
|
|
||||||
? "#7872BF"
|
|
||||||
: "#CC5454"
|
|
||||||
}
|
|
||||||
showHead={false}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-purple" /> Correct
|
<div className="w-4 h-4 rounded-full bg-mti-purple" /> Correct
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
import reactStringReplace from "react-string-replace";
|
||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
|
|
||||||
function Question({
|
function Question({
|
||||||
|
id,
|
||||||
variant,
|
variant,
|
||||||
prompt,
|
prompt,
|
||||||
solution,
|
solution,
|
||||||
options,
|
options,
|
||||||
userSolution,
|
userSolution,
|
||||||
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
||||||
|
const renderPrompt = (prompt: string) => {
|
||||||
|
return reactStringReplace(prompt, /((<u>)[\w\s']+(<\/u>))/g, (match) => {
|
||||||
|
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
|
||||||
|
return word.length > 0 ? <u>{word}</u> : null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const optionColor = (option: string) => {
|
const optionColor = (option: string) => {
|
||||||
if (option === solution && !userSolution) {
|
if (option === solution && !userSolution) {
|
||||||
return "!border-mti-gray-davy !text-mti-gray-davy";
|
return "!border-mti-gray-davy !text-mti-gray-davy";
|
||||||
@@ -26,7 +36,15 @@ function Question({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<span>{prompt}</span>
|
{isNaN(Number(id)) ? (
|
||||||
|
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||||
|
) : (
|
||||||
|
<span className="">
|
||||||
|
<>
|
||||||
|
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||||
|
</>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-4 gap-4 place-items-center">
|
<div className="grid grid-cols-4 gap-4 place-items-center">
|
||||||
{variant === "image" &&
|
{variant === "image" &&
|
||||||
options.map((option) => (
|
options.map((option) => (
|
||||||
@@ -54,17 +72,8 @@ function Question({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MultipleChoice({
|
export default function MultipleChoice({id, type, prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
||||||
id,
|
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||||
type,
|
|
||||||
prompt,
|
|
||||||
questions,
|
|
||||||
userSolutions,
|
|
||||||
updateIndex,
|
|
||||||
onNext,
|
|
||||||
onBack,
|
|
||||||
}: MultipleChoiceExercise & CommonProps) {
|
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = questions.length;
|
const total = questions.length;
|
||||||
@@ -76,15 +85,11 @@ export default function MultipleChoice({
|
|||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (updateIndex) updateIndex(questionIndex);
|
|
||||||
}, [questionIndex, updateIndex]);
|
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
if (questionIndex === questions.length - 1) {
|
if (questionIndex === questions.length - 1) {
|
||||||
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
||||||
} else {
|
} else {
|
||||||
setQuestionIndex((prev) => prev + 1);
|
setQuestionIndex(questionIndex + 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -92,7 +97,7 @@ export default function MultipleChoice({
|
|||||||
if (questionIndex === 0) {
|
if (questionIndex === 0) {
|
||||||
onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
||||||
} else {
|
} else {
|
||||||
setQuestionIndex((prev) => prev - 1);
|
setQuestionIndex(questionIndex - 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
|
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
|
||||||
|
const solution = userSolutions[0].solution;
|
||||||
|
|
||||||
|
if (solution.startsWith("https://")) return setSolutionURL(solution);
|
||||||
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
|
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
|
||||||
const blob = new Blob([data], {type: "audio/wav"});
|
const blob = new Blob([data], {type: "audio/wav"});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -123,11 +126,16 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<div className="flex gap-4 px-1">
|
<div className="flex gap-4 px-1">
|
||||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
||||||
<div className="bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2" key={key}>
|
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
||||||
{key}: Level {userSolutions[0].evaluation!.task_response[key]}
|
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||||
</div>
|
|
||||||
))}
|
return (
|
||||||
|
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2")} key={key}>
|
||||||
|
{key}: Level {grade}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{userSolutions[0].evaluation &&
|
{userSolutions[0].evaluation &&
|
||||||
(userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? (
|
(userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? (
|
||||||
@@ -155,6 +163,17 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
}>
|
}>
|
||||||
Recommended Answer
|
Recommended Answer
|
||||||
</Tab>
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
className={({selected}) =>
|
||||||
|
clsx(
|
||||||
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||||
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
Global Overview
|
||||||
|
</Tab>
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels>
|
<Tab.Panels>
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
@@ -168,6 +187,23 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
userSolutions[0].evaluation!.perfect_answer_1.replaceAll(/\s{2,}/g, "\n\n")}
|
userSolutions[0].evaluation!.perfect_answer_1.replaceAll(/\s{2,}/g, "\n\n")}
|
||||||
</span>
|
</span>
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
||||||
|
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
||||||
|
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex flex-col gap-2">
|
||||||
|
<span className={"font-semibold"}>
|
||||||
|
{key}: Level {grade}
|
||||||
|
</span>
|
||||||
|
{typeof taskResponse !== "number" && <span>{taskResponse.comment}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Tab.Panel>
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export default function WriteBlanksSolutions({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full mb-20">
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
<span className="text-sm w-full leading-6">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
|
|||||||
@@ -7,11 +7,17 @@ import {Dialog, Tab, Transition} from "@headlessui/react";
|
|||||||
import {writingReverseMarking} from "@/utils/score";
|
import {writingReverseMarking} from "@/utils/score";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
||||||
|
import useUser from "@/hooks/useUser";
|
||||||
|
import AIDetection from "../AIDetection";
|
||||||
|
|
||||||
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
|
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [showDiff, setShowDiff] = useState(false);
|
const [showDiff, setShowDiff] = useState(false);
|
||||||
|
|
||||||
|
const {user} = useUser();
|
||||||
|
|
||||||
|
const aiEval = userSolutions && userSolutions.length > 0 ? userSolutions[0].evaluation?.ai_detection : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{attachment && (
|
{attachment && (
|
||||||
@@ -117,11 +123,16 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<div className="flex gap-4 px-1">
|
<div className="flex gap-4 px-1">
|
||||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
||||||
<div className="bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2" key={key}>
|
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
||||||
{key}: Level {userSolutions[0].evaluation!.task_response[key]}
|
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||||
</div>
|
|
||||||
))}
|
return (
|
||||||
|
<div className={clsx("bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2")} key={key}>
|
||||||
|
{key}: Level {grade}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{userSolutions[0].evaluation && userSolutions[0].evaluation.perfect_answer ? (
|
{userSolutions[0].evaluation && userSolutions[0].evaluation.perfect_answer ? (
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
@@ -148,6 +159,30 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
}>
|
}>
|
||||||
Recommended Answer
|
Recommended Answer
|
||||||
</Tab>
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
className={({selected}) =>
|
||||||
|
clsx(
|
||||||
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
||||||
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
Global Overview
|
||||||
|
</Tab>
|
||||||
|
{aiEval && user?.type !== "student" && (
|
||||||
|
<Tab
|
||||||
|
className={({selected}) =>
|
||||||
|
clsx(
|
||||||
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
||||||
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
AI Use
|
||||||
|
</Tab>
|
||||||
|
)}
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels>
|
<Tab.Panels>
|
||||||
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
@@ -158,6 +193,28 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
{userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n").replaceAll("\\n", "\n")}
|
{userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n").replaceAll("\\n", "\n")}
|
||||||
</span>
|
</span>
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
|
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
||||||
|
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
||||||
|
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex flex-col gap-2">
|
||||||
|
<span className={"font-semibold"}>
|
||||||
|
{key}: Level {grade}
|
||||||
|
</span>
|
||||||
|
{typeof taskResponse !== "number" && <span>{taskResponse.comment}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Tab.Panel>
|
||||||
|
{aiEval && user?.type !== "student" && (
|
||||||
|
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<AIDetection {...aiEval} />
|
||||||
|
</Tab.Panel>
|
||||||
|
)}
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import Writing from "./Writing";
|
|||||||
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
|
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
|
||||||
|
|
||||||
export interface CommonProps {
|
export interface CommonProps {
|
||||||
updateIndex?: (internalIndex: number) => void;
|
|
||||||
onNext: (userSolutions: UserSolution) => void;
|
onNext: (userSolutions: UserSolution) => void;
|
||||||
onBack: (userSolutions: UserSolution) => void;
|
onBack: (userSolutions: UserSolution) => void;
|
||||||
}
|
}
|
||||||
@@ -36,15 +35,7 @@ export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: (
|
|||||||
case "matchSentences":
|
case "matchSentences":
|
||||||
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "multipleChoice":
|
case "multipleChoice":
|
||||||
return (
|
return <MultipleChoice key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
<MultipleChoice
|
|
||||||
key={exercise.id}
|
|
||||||
{...(exercise as MultipleChoiceExercise)}
|
|
||||||
updateIndex={updateIndex}
|
|
||||||
onNext={onNext}
|
|
||||||
onBack={onBack}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "writeBlanks":
|
case "writeBlanks":
|
||||||
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "writing":
|
case "writing":
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
export type Error = "E001" | "E002";
|
export type Error = "E001" | "E002" | "E003";
|
||||||
export interface ErrorMessage {
|
export interface ErrorMessage {
|
||||||
error: Error;
|
error: Error;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -7,4 +7,5 @@ export interface ErrorMessage {
|
|||||||
export const errorMessages: {[key in Error]: string} = {
|
export const errorMessages: {[key in Error]: string} = {
|
||||||
E001: "Wrong password!",
|
E001: "Wrong password!",
|
||||||
E002: "Invalid e-mail",
|
E002: "Invalid e-mail",
|
||||||
|
E003: "E-mail already in use!",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,4 +10,4 @@
|
|||||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-3ml0u%40storied-phalanx-349916.iam.gserviceaccount.com",
|
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-3ml0u%40storied-phalanx-349916.iam.gserviceaccount.com",
|
||||||
"universe_domain": "googleapis.com"
|
"universe_domain": "googleapis.com"
|
||||||
}
|
}
|
||||||
13
src/constants/staging.json
Normal file
13
src/constants/staging.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"type": "service_account",
|
||||||
|
"project_id": "encoach-staging",
|
||||||
|
"private_key_id": "5718a649419776df9637589f8696a258a6a70f6c",
|
||||||
|
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC2C6Es2gY8lLvH\ndVilNtRNm9glSaPXMNw2PzZZbSGuG1uGPFaCzlq1lOb2u17YfMG4GriKIMjIQKXF\nqdvxA8CAmAFRuDjUGmpbO/X1ZW7amOs5Bjed2BYmL01dEqzzwwh7rEfNDjeghRPx\n1uKzH8A6TLT5xq+74I5K1CIgiljBpZimsERu2SDawjkdtZfA7qoylA46Nq66LuwQ\nVyv9CK2SZNpBcT3sunCmRsrCzmSTzKdbcqRPdqUKgZOH/Rjp0sw9VuUgwoxdGZV3\n5SJjObo5ceZ1OSiJm7GwLzp7uq16sqycgSYwppNLI5OtzOfSuWbGD4+a044t2Mlq\n9PHXv7H/AgMBAAECggEAAfhKlFwq8MaL6PggRJq9HbaKgQ4fcOmCmy8AQmPNF1UM\nyVKSKGndjxUfPLCWsaaunUnjZlHoKkvndKXxDyttuVaBE9EiWEqNjRLZ3KpuJ9Jm\nH+CtLbmUCnISQb1n1AlvvZAwhLZbLBL/PhYyWiLapybZAdJAaOWLVKGgBD8gVRQW\nJFCqnszX1O2YlpWHutb979R4qoY/XAf94gyMkTpXZwuETvFqZbau2vxRZ8qARix3\nmic881PwiF6Cod8UPCS9yMK+Q+Se6SomwXU9PCmlummn9xmQBAxYy8gIAVs/J9Fg\n5SvhnImAPDd+zIzzw2cHCiruNWIhroMVZDZJgWdY1QKBgQDjTKKeFOur3ijJJL2/\nWg1SE2jLP0GpXzM5YMx6jdOCNDCzugPngRucRXiTkJ2FnUgyMcQyi6hyrbWXN/6z\nXhx5fwLB4tnTcqOMvNfcay5mDk3RW9ZZJxayB54Sf1Nm/4xiDBnGPT+iHQvK+/pT\nwScWznFkmk60E796o76OLn3PEwKBgQDNCC2uPq+uOcCopIO8HH88gqdxTvpbeHUU\nrdJOmr1VtGNuvay/mfpva9+VEtGbZTFzjhfvfCEIjpj3Llh8Flb9EYa6BmscBiyp\ngszEeFuB3zHndlSCZPnGJ7JiRAdPAEgG3Gl/r9th6PDaEMq0MFS5i7GGhPBIRYCG\nUtmY5eVy5QKBgH5Nuls/YsnJFD7ZNLscziQadvPhvZnhNbSfjmBXaP2EBMAKEFtX\nCcGndN4C0RVLFbAWqWAw7LR0xGA4FEcVd5snsZ+Nb98oZ6sv0H9B67F4J1O7xXsa\n1mitBPBgYjbsr9RXxwa6SB7MJx5vMGXUAeWRZ78wY6V7B76dOKkHOo+TAoGBAJf5\nBOsPueZZFm2qK58GPGVcrsI0+StNuPLP+H+dANQC9mTCIMaQWmm2Oq5jmYwmUKZH\nX4R6rH2MPOOSrbGkWWwRTpyaX1ARX49xzVefoqw8BOB8/Bz+vYjcKcPeitBK9Bhp\nzaUAc4s6PzRTl/xBirtRSQ/df8ECC0cFKBbF6PHlAoGAGqnlpo+k8vAtg6ulCuGu\nx2Y/c5UmvXGHk60pccnW3UtENSDnl99OgMfBz8/qLAMWs6DUQ/kvSlHQPmMBHRWZ\nNTr6ceGXyNs4KdYoj1K7AU3c0Lm0wyQ2giQMoOOUQAm98Xr8z5aiihj10hHPmzzL\n9kwpOmZpjNmC/ERD69imWhY=\n-----END PRIVATE KEY-----\n",
|
||||||
|
"client_email": "firebase-adminsdk-8rs9e@encoach-staging.iam.gserviceaccount.com",
|
||||||
|
"client_id": "108221424237414412378",
|
||||||
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||||
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
|
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||||
|
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-8rs9e%40encoach-staging.iam.gserviceaccount.com",
|
||||||
|
"universe_domain": "googleapis.com"
|
||||||
|
}
|
||||||
@@ -1,39 +1,91 @@
|
|||||||
import {Type} from "@/interfaces/user";
|
import { Type } from "@/interfaces/user";
|
||||||
|
|
||||||
export const PERMISSIONS = {
|
export const PERMISSIONS = {
|
||||||
generateCode: {
|
generateCode: {
|
||||||
student: ["corporate", "developer", "admin"],
|
student: ["corporate", "developer", "admin", "mastercorporate"],
|
||||||
teacher: ["corporate", "developer", "admin"],
|
teacher: ["corporate", "developer", "admin", "mastercorporate"],
|
||||||
corporate: ["admin", "developer"],
|
corporate: ["admin", "developer"],
|
||||||
admin: ["developer", "admin"],
|
mastercorporate: ["admin", "developer"],
|
||||||
agent: ["developer", "admin"],
|
|
||||||
developer: ["developer"],
|
admin: ["developer", "admin"],
|
||||||
},
|
agent: ["developer", "admin"],
|
||||||
deleteUser: {
|
developer: ["developer"],
|
||||||
student: ["corporate", "developer", "admin"],
|
},
|
||||||
teacher: ["corporate", "developer", "admin"],
|
deleteUser: {
|
||||||
corporate: ["admin", "developer"],
|
student: {
|
||||||
admin: ["developer", "admin"],
|
perm: "deleteStudent",
|
||||||
agent: ["developer", "admin"],
|
list: ["corporate", "developer", "admin", "mastercorporate"],
|
||||||
developer: ["developer"],
|
},
|
||||||
},
|
teacher: {
|
||||||
updateUser: {
|
perm: "deleteTeacher",
|
||||||
student: ["developer", "admin"],
|
list: ["corporate", "developer", "admin", "mastercorporate"],
|
||||||
teacher: ["developer", "admin"],
|
},
|
||||||
corporate: ["admin", "developer"],
|
corporate: {
|
||||||
admin: ["developer", "admin"],
|
perm: "deleteCorporate",
|
||||||
agent: ["developer", "admin"],
|
list: ["admin", "developer"],
|
||||||
developer: ["developer"],
|
},
|
||||||
},
|
mastercorporate: {
|
||||||
updateExpiryDate: {
|
perm: undefined,
|
||||||
student: ["developer", "admin"],
|
list: ["admin", "developer"],
|
||||||
teacher: ["developer", "admin"],
|
},
|
||||||
corporate: ["admin", "developer"],
|
|
||||||
admin: ["developer", "admin"],
|
admin: {
|
||||||
agent: ["developer", "admin"],
|
perm: "deleteAdmin",
|
||||||
developer: ["developer"],
|
list: ["developer", "admin"],
|
||||||
},
|
},
|
||||||
examManagement: {
|
agent: {
|
||||||
delete: ["developer", "admin"],
|
perm: "deleteCountryManager",
|
||||||
},
|
list: ["developer", "admin"],
|
||||||
|
},
|
||||||
|
developer: {
|
||||||
|
perm: undefined,
|
||||||
|
list: ["developer"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updateUser: {
|
||||||
|
student: {
|
||||||
|
perm: "editStudent",
|
||||||
|
list: ["developer", "admin"],
|
||||||
|
},
|
||||||
|
teacher: {
|
||||||
|
perm: "editTeacher",
|
||||||
|
list: ["developer", "admin"],
|
||||||
|
},
|
||||||
|
|
||||||
|
corporate: {
|
||||||
|
perm: "editCorporate",
|
||||||
|
list: ["admin", "developer"],
|
||||||
|
},
|
||||||
|
mastercorporate: {
|
||||||
|
perm: undefined,
|
||||||
|
list: ["admin", "developer"],
|
||||||
|
},
|
||||||
|
|
||||||
|
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"],
|
||||||
|
|
||||||
|
admin: ["developer", "admin"],
|
||||||
|
agent: ["developer", "admin"],
|
||||||
|
developer: ["developer"],
|
||||||
|
},
|
||||||
|
examManagement: {
|
||||||
|
delete: ["developer", "admin"],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,243 +2,294 @@
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useStats from "@/hooks/useStats";
|
import useStats from "@/hooks/useStats";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import { User} from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
import {dateSorter} from "@/utils";
|
import { dateSorter } from "@/utils";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {BsArrowLeft, BsPersonFill, BsBank, BsCurrencyDollar} from "react-icons/bs";
|
import {
|
||||||
|
BsArrowLeft,
|
||||||
|
BsPersonFill,
|
||||||
|
BsBank,
|
||||||
|
BsCurrencyDollar,
|
||||||
|
} from "react-icons/bs";
|
||||||
import UserCard from "@/components/UserCard";
|
import UserCard from "@/components/UserCard";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
|
||||||
import IconCard from "./IconCard";
|
import IconCard from "./IconCard";
|
||||||
import usePaymentStatusUsers from '@/hooks/usePaymentStatusUsers';
|
import usePaymentStatusUsers from "@/hooks/usePaymentStatusUsers";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AgentDashboard({user}: Props) {
|
export default function AgentDashboard({ user }: Props) {
|
||||||
const [page, setPage] = useState("");
|
const [page, setPage] = useState("");
|
||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
const {stats} = useStats();
|
const { stats } = useStats();
|
||||||
const {users, reload} = useUsers();
|
const { users, reload } = useUsers();
|
||||||
const {groups} = useGroups(user.id);
|
const { groups } = useGroups(user.id);
|
||||||
const { pending, done } = usePaymentStatusUsers();
|
const { pending, done } = usePaymentStatusUsers();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowModal(!!selectedUser && page === "");
|
setShowModal(!!selectedUser && page === "");
|
||||||
}, [selectedUser, page]);
|
}, [selectedUser, page]);
|
||||||
|
|
||||||
const corporateFilter = (user: User) => user.type === "corporate";
|
const corporateFilter = (user: User) => user.type === "corporate";
|
||||||
const referredCorporateFilter = (x: User) =>
|
const referredCorporateFilter = (x: User) =>
|
||||||
x.type === "corporate" && !!x.corporateInformation && x.corporateInformation.referralAgent === user.id;
|
x.type === "corporate" &&
|
||||||
const inactiveReferredCorporateFilter = (x: User) =>
|
!!x.corporateInformation &&
|
||||||
referredCorporateFilter(x) && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
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}) => (
|
const UserDisplay = ({
|
||||||
<div
|
displayUser,
|
||||||
onClick={() => allowClick && setSelectedUser(displayUser)}
|
allowClick = true,
|
||||||
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" />
|
displayUser: User;
|
||||||
<div className="flex flex-col gap-1 items-start">
|
allowClick?: boolean;
|
||||||
<span>
|
}) => (
|
||||||
{displayUser.type === "corporate"
|
<div
|
||||||
? displayUser.corporateInformation?.companyInformation?.name || displayUser.name
|
onClick={() => allowClick && setSelectedUser(displayUser)}
|
||||||
: displayUser.name}
|
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"
|
||||||
</span>
|
>
|
||||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
<img
|
||||||
</div>
|
src={displayUser.profilePicture}
|
||||||
</div>
|
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 = () => {
|
const ReferredCorporateList = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<UserList
|
||||||
<div className="flex flex-col gap-4">
|
user={user}
|
||||||
<div
|
filters={[referredCorporateFilter]}
|
||||||
onClick={() => setPage("")}
|
renderHeader={(total) => (
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
<div className="flex flex-col gap-4">
|
||||||
<BsArrowLeft className="text-xl" />
|
<div
|
||||||
<span>Back</span>
|
onClick={() => setPage("")}
|
||||||
</div>
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||||
<h2 className="text-2xl font-semibold">Referred Corporate ({users.filter(referredCorporateFilter).length})</h2>
|
>
|
||||||
</div>
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">
|
||||||
|
Referred Corporate ({total})
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
<UserList user={user} filters={[referredCorporateFilter]} />
|
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 = () => {
|
const CorporateList = () => {
|
||||||
return (
|
const filter = (x: User) => x.type === "corporate";
|
||||||
<>
|
|
||||||
<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 ({users.filter(inactiveReferredCorporateFilter).length})</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UserList user={user} filters={[inactiveReferredCorporateFilter]} />
|
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 CorporateList = () => {
|
const CorporatePaidStatusList = ({ paid }: { paid: Boolean }) => {
|
||||||
const filter = (x: User) => x.type === "corporate";
|
const list = paid ? done : pending;
|
||||||
|
const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<UserList
|
||||||
<div className="flex flex-col gap-4">
|
user={user}
|
||||||
<div
|
filters={[filter]}
|
||||||
onClick={() => setPage("")}
|
renderHeader={(total) => (
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
<div className="flex flex-col gap-4">
|
||||||
<BsArrowLeft className="text-xl" />
|
<div
|
||||||
<span>Back</span>
|
onClick={() => setPage("")}
|
||||||
</div>
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||||
<h2 className="text-2xl font-semibold">Corporate ({users.filter(filter).length})</h2>
|
>
|
||||||
</div>
|
<BsArrowLeft className="text-xl" />
|
||||||
<UserList user={user} filters={[filter]} />
|
<span>Back</span>
|
||||||
</>
|
</div>
|
||||||
);
|
<h2 className="text-2xl font-semibold">
|
||||||
};
|
{paid ? "Payment Done" : "Pending Payment"} ({total})
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const CorporatePaidStatusList = ({ paid }: {paid: Boolean}) => {
|
const DefaultDashboard = () => (
|
||||||
const list = paid ? done : pending;
|
<>
|
||||||
const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
|
<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>
|
||||||
|
|
||||||
return (
|
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
||||||
<>
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<div className="flex flex-col gap-4">
|
<span className="p-4">Latest Referred Corporate</span>
|
||||||
<div
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
onClick={() => setPage("")}
|
{users
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
.filter(referredCorporateFilter)
|
||||||
<BsArrowLeft className="text-xl" />
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
<span>Back</span>
|
.map((x) => (
|
||||||
</div>
|
<UserDisplay key={x.id} displayUser={x} />
|
||||||
<h2 className="text-2xl font-semibold">{paid ? 'Payment Done' : 'Pending Payment'} ({list.length})</h2>
|
))}
|
||||||
</div>
|
</div>
|
||||||
<UserList user={user} filters={[filter]} />
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
const DefaultDashboard = () => (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:gap-4 text-center">
|
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
|
||||||
<IconCard
|
<>
|
||||||
onClick={() => setPage("referredCorporate")}
|
{selectedUser && (
|
||||||
Icon={BsBank}
|
<div className="w-full flex flex-col gap-8">
|
||||||
label="Referred Corporate"
|
<UserCard
|
||||||
value={users.filter(referredCorporateFilter).length}
|
loggedInUser={user}
|
||||||
color="purple"
|
onClose={(shouldReload) => {
|
||||||
/>
|
setSelectedUser(undefined);
|
||||||
<IconCard
|
if (shouldReload) reload();
|
||||||
onClick={() => setPage("inactiveReferredCorporate")}
|
}}
|
||||||
Icon={BsBank}
|
onViewStudents={
|
||||||
label="Inactive Referred Corporate"
|
selectedUser.type === "corporate" ||
|
||||||
value={users.filter(inactiveReferredCorporateFilter).length}
|
selectedUser.type === "teacher"
|
||||||
color="rose"
|
? () => setPage("students")
|
||||||
/>
|
: undefined
|
||||||
<IconCard
|
}
|
||||||
onClick={() => setPage("corporate")}
|
onViewTeachers={
|
||||||
Icon={BsBank}
|
selectedUser.type === "corporate"
|
||||||
label="Corporate"
|
? () => setPage("teachers")
|
||||||
value={users.filter(corporateFilter).length}
|
: undefined
|
||||||
color="purple"
|
}
|
||||||
/>
|
user={selectedUser}
|
||||||
<IconCard
|
/>
|
||||||
onClick={() => setPage("paymentdone")}
|
</div>
|
||||||
Icon={BsCurrencyDollar}
|
)}
|
||||||
label="Payment Done"
|
</>
|
||||||
value={done.length}
|
</Modal>
|
||||||
color="purple"
|
{page === "referredCorporate" && <ReferredCorporateList />}
|
||||||
/>
|
{page === "corporate" && <CorporateList />}
|
||||||
<IconCard
|
{page === "inactiveReferredCorporate" && (
|
||||||
onClick={() => setPage("paymentpending")}
|
<InactiveReferredCorporateList />
|
||||||
Icon={BsCurrencyDollar}
|
)}
|
||||||
label="Pending Payment"
|
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
|
||||||
value={pending.length}
|
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
|
||||||
color="rose"
|
{page === "" && <DefaultDashboard />}
|
||||||
/>
|
</>
|
||||||
</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 />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,126 +1,105 @@
|
|||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import { Module } from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import { Assignment } from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
import { calculateBandScore } from "@/utils/score";
|
import {calculateBandScore} from "@/utils/score";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {
|
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||||
BsBook,
|
import {usePDFDownload} from "@/hooks/usePDFDownload";
|
||||||
BsClipboard,
|
import {useAssignmentArchive} from "@/hooks/useAssignmentArchive";
|
||||||
BsHeadphones,
|
import {uniqBy} from "lodash";
|
||||||
BsMegaphone,
|
import {useAssignmentUnarchive} from "@/hooks/useAssignmentUnarchive";
|
||||||
BsPen,
|
|
||||||
} from "react-icons/bs";
|
|
||||||
import { usePDFDownload } from "@/hooks/usePDFDownload";
|
|
||||||
import { useAssignmentArchive } from "@/hooks/useAssignmentArchive";
|
|
||||||
import { uniqBy } from "lodash";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
allowDownload?: boolean;
|
allowDownload?: boolean;
|
||||||
reload?: Function;
|
reload?: Function;
|
||||||
allowArchive?: boolean;
|
allowArchive?: boolean;
|
||||||
|
allowUnarchive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssignmentCard({
|
export default function AssignmentCard({
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
assigner,
|
assigner,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
assignees,
|
assignees,
|
||||||
results,
|
results,
|
||||||
exams,
|
exams,
|
||||||
archived,
|
archived,
|
||||||
onClick,
|
onClick,
|
||||||
allowDownload,
|
allowDownload,
|
||||||
reload,
|
reload,
|
||||||
allowArchive,
|
allowArchive,
|
||||||
|
allowUnarchive,
|
||||||
}: Assignment & Props) {
|
}: Assignment & Props) {
|
||||||
const renderPdfIcon = usePDFDownload("assignments");
|
const renderPdfIcon = usePDFDownload("assignments");
|
||||||
const renderArchiveIcon = useAssignmentArchive(id, reload);
|
const renderArchiveIcon = useAssignmentArchive(id, reload);
|
||||||
|
const renderUnarchiveIcon = useAssignmentUnarchive(id, reload);
|
||||||
|
|
||||||
const calculateAverageModuleScore = (module: Module) => {
|
const calculateAverageModuleScore = (module: Module) => {
|
||||||
const resultModuleBandScores = results.map((r) => {
|
const resultModuleBandScores = results.map((r) => {
|
||||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||||
|
|
||||||
const correct = moduleStats.reduce(
|
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
||||||
(acc, curr) => acc + curr.score.correct,
|
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
||||||
0
|
return calculateBandScore(correct, total, module, r.type);
|
||||||
);
|
});
|
||||||
const total = moduleStats.reduce(
|
|
||||||
(acc, curr) => acc + curr.score.total,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
return calculateBandScore(correct, total, module, r.type);
|
|
||||||
});
|
|
||||||
|
|
||||||
return resultModuleBandScores.length === 0
|
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / results.length;
|
||||||
? -1
|
};
|
||||||
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
|
|
||||||
results.length;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="border-mti-gray-platinum flex h-fit w-[350px] cursor-pointer flex-col gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow"
|
className="border-mti-gray-platinum flex h-fit w-[350px] cursor-pointer flex-col gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow">
|
||||||
>
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-row justify-between">
|
||||||
<div className="flex flex-row justify-between">
|
<h3 className="text-xl font-semibold">{name}</h3>
|
||||||
<h3 className="text-xl font-semibold">{name}</h3>
|
<div className="flex gap-2">
|
||||||
<div className="flex gap-2">
|
{allowDownload && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
{allowDownload &&
|
{allowArchive && !archived && renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
{allowUnarchive && archived && renderUnarchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
{allowArchive &&
|
</div>
|
||||||
!archived &&
|
</div>
|
||||||
renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
<ProgressBar
|
||||||
</div>
|
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
|
||||||
</div>
|
percentage={(results.length / assignees.length) * 100}
|
||||||
<ProgressBar
|
label={`${results.length}/${assignees.length}`}
|
||||||
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
|
className="h-5"
|
||||||
percentage={(results.length / assignees.length) * 100}
|
textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"}
|
||||||
label={`${results.length}/${assignees.length}`}
|
/>
|
||||||
className="h-5"
|
</div>
|
||||||
textClassName={
|
<span className="flex justify-between gap-1">
|
||||||
results.length / assignees.length < 0.5
|
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
? "!text-mti-gray-dim font-light"
|
<span>-</span>
|
||||||
: "text-white"
|
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
}
|
</span>
|
||||||
/>
|
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||||
</div>
|
{uniqBy(exams, (x) => x.module).map(({module}) => (
|
||||||
<span className="flex justify-between gap-1">
|
<div
|
||||||
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
|
key={module}
|
||||||
<span>-</span>
|
className={clsx(
|
||||||
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
|
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||||
</span>
|
module === "reading" && "bg-ielts-reading",
|
||||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
module === "listening" && "bg-ielts-listening",
|
||||||
{uniqBy(exams, (x) => x.module).map(({ module }) => (
|
module === "writing" && "bg-ielts-writing",
|
||||||
<div
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
key={module}
|
module === "level" && "bg-ielts-level",
|
||||||
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" && <BsBook className="h-4 w-4" />}
|
||||||
module === "reading" && "bg-ielts-reading",
|
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||||
module === "listening" && "bg-ielts-listening",
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
module === "writing" && "bg-ielts-writing",
|
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||||
module === "level" && "bg-ielts-level"
|
{calculateAverageModuleScore(module) > -1 && (
|
||||||
)}
|
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
||||||
>
|
)}
|
||||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
</div>
|
||||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
))}
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
</div>
|
||||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
</div>
|
||||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
);
|
||||||
{calculateAverageModuleScore(module) > -1 && (
|
|
||||||
<span className="text-sm">
|
|
||||||
{calculateAverageModuleScore(module).toFixed(1)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Input from "@/components/Low/Input";
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {BsBook, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
import {BsBook, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
||||||
import {generate} from "random-words";
|
import {generate} from "random-words";
|
||||||
import {capitalize} from "lodash";
|
import {capitalize} from "lodash";
|
||||||
@@ -16,11 +16,11 @@ import moment from "moment";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {getExam} from "@/utils/exams";
|
import {getExam} from "@/utils/exams";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import {uuidv4} from "@firebase/util";
|
|
||||||
import {Assignment} from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import {InstructorGender, Variant} from "@/interfaces/exam";
|
import {InstructorGender, Variant} from "@/interfaces/exam";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
|
import useExams from "@/hooks/useExams";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isCreating: boolean;
|
isCreating: boolean;
|
||||||
@@ -44,6 +44,14 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied");
|
const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied");
|
||||||
// creates a new exam for each assignee or just one exam for all assignees
|
// creates a new exam for each assignee or just one exam for all assignees
|
||||||
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
|
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
|
||||||
|
const [useRandomExams, setUseRandomExams] = useState(true);
|
||||||
|
const [examIDs, setExamIDs] = useState<{id: string; module: Module}[]>([]);
|
||||||
|
|
||||||
|
const {exams} = useExams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module)));
|
||||||
|
}, [selectedModules]);
|
||||||
|
|
||||||
const toggleModule = (module: Module) => {
|
const toggleModule = (module: Module) => {
|
||||||
const modules = selectedModules.filter((x) => x !== module);
|
const modules = selectedModules.filter((x) => x !== module);
|
||||||
@@ -61,6 +69,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
assignees,
|
assignees,
|
||||||
name,
|
name,
|
||||||
startDate,
|
startDate,
|
||||||
|
examIDs: !useRandomExams ? examIDs : undefined,
|
||||||
endDate,
|
endDate,
|
||||||
selectedModules,
|
selectedModules,
|
||||||
generateMultiple,
|
generateMultiple,
|
||||||
@@ -229,19 +238,52 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 w-full">
|
{selectedModules.includes("speaking") && (
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor's Gender</label>
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<Select
|
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor's Gender</label>
|
||||||
value={{value: instructorGender, label: capitalize(instructorGender)}}
|
<Select
|
||||||
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)}
|
value={{value: instructorGender, label: capitalize(instructorGender)}}
|
||||||
disabled={!selectedModules.includes("speaking") || !!assignment}
|
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)}
|
||||||
options={[
|
disabled={!selectedModules.includes("speaking") || !!assignment}
|
||||||
{value: "male", label: "Male"},
|
options={[
|
||||||
{value: "female", label: "Female"},
|
{value: "male", label: "Male"},
|
||||||
{value: "varied", label: "Varied"},
|
{value: "female", label: "Female"},
|
||||||
]}
|
{value: "varied", label: "Varied"},
|
||||||
/>
|
]}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedModules.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<Checkbox isChecked={useRandomExams} onChange={setUseRandomExams}>
|
||||||
|
Random Exams
|
||||||
|
</Checkbox>
|
||||||
|
{!useRandomExams && (
|
||||||
|
<div className="grid md:grid-cols-2 w-full gap-4">
|
||||||
|
{selectedModules.map((module) => (
|
||||||
|
<div key={module} className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">{capitalize(module)} Exam</label>
|
||||||
|
<Select
|
||||||
|
value={{
|
||||||
|
value: examIDs.find((e) => e.module === module)?.id || null,
|
||||||
|
label: examIDs.find((e) => e.module === module)?.id || "",
|
||||||
|
}}
|
||||||
|
onChange={(value) =>
|
||||||
|
value
|
||||||
|
? setExamIDs((prev) => [...prev.filter((x) => x.module !== module), {id: value.value!, module}])
|
||||||
|
: setExamIDs((prev) => prev.filter((x) => x.module !== module))
|
||||||
|
}
|
||||||
|
options={exams
|
||||||
|
.filter((x) => !x.isDiagnostic && x.module === module)
|
||||||
|
.map((x) => ({value: x.id, label: x.id}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="w-full flex flex-col gap-3">
|
<section className="w-full flex flex-col gap-3">
|
||||||
<span className="font-semibold">Assignees ({assignees.length} selected)</span>
|
<span className="font-semibold">Assignees ({assignees.length} selected)</span>
|
||||||
@@ -323,7 +365,14 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
disabled={selectedModules.length === 0 || !name || !startDate || !endDate || assignees.length === 0}
|
disabled={
|
||||||
|
selectedModules.length === 0 ||
|
||||||
|
!name ||
|
||||||
|
!startDate ||
|
||||||
|
!endDate ||
|
||||||
|
assignees.length === 0 ||
|
||||||
|
(!!examIDs && examIDs.length < selectedModules.length)
|
||||||
|
}
|
||||||
className="w-full max-w-[200px]"
|
className="w-full max-w-[200px]"
|
||||||
onClick={createAssignment}
|
onClick={createAssignment}
|
||||||
isLoading={isLoading}>
|
isLoading={isLoading}>
|
||||||
|
|||||||
@@ -2,325 +2,412 @@
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useStats from "@/hooks/useStats";
|
import useStats from "@/hooks/useStats";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {CorporateUser, Group, Stat, User} from "@/interfaces/user";
|
import { CorporateUser, Group, Stat, User } from "@/interfaces/user";
|
||||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
import {dateSorter} from "@/utils";
|
import { dateSorter } from "@/utils";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
BsArrowLeft,
|
BsArrowLeft,
|
||||||
BsClipboard2Data,
|
BsClipboard2Data,
|
||||||
BsClipboard2DataFill,
|
BsClipboard2DataFill,
|
||||||
BsClock,
|
BsClock,
|
||||||
BsGlobeCentralSouthAsia,
|
BsGlobeCentralSouthAsia,
|
||||||
BsPaperclip,
|
BsPaperclip,
|
||||||
BsPerson,
|
BsPerson,
|
||||||
BsPersonAdd,
|
BsPersonAdd,
|
||||||
BsPersonFill,
|
BsPersonFill,
|
||||||
BsPersonFillGear,
|
BsPersonFillGear,
|
||||||
BsPersonGear,
|
BsPersonGear,
|
||||||
BsPencilSquare,
|
BsPencilSquare,
|
||||||
BsPersonBadge,
|
BsPersonBadge,
|
||||||
BsPersonCheck,
|
BsPersonCheck,
|
||||||
BsPeople,
|
BsPeople,
|
||||||
} from "react-icons/bs";
|
} from "react-icons/bs";
|
||||||
import UserCard from "@/components/UserCard";
|
import UserCard from "@/components/UserCard";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
|
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
|
||||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
||||||
import {Module} from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import {groupByExam} from "@/utils/stats";
|
import { groupByExam } from "@/utils/stats";
|
||||||
import IconCard from "./IconCard";
|
import IconCard from "./IconCard";
|
||||||
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
||||||
import useFilterStore from "@/stores/listFilterStore";
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import useCodes from "@/hooks/useCodes";
|
import useCodes from "@/hooks/useCodes";
|
||||||
|
import { getUserCorporate } from "@/utils/groups";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: CorporateUser;
|
user: CorporateUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CorporateDashboard({user}: Props) {
|
export default function CorporateDashboard({ user }: Props) {
|
||||||
const [page, setPage] = useState("");
|
const [page, setPage] = useState("");
|
||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [corporateUserToShow, setCorporateUserToShow] =
|
||||||
|
useState<CorporateUser>();
|
||||||
|
|
||||||
const {stats} = useStats();
|
const { stats } = useStats();
|
||||||
const {users, reload} = useUsers();
|
const { users, reload } = useUsers();
|
||||||
const {codes} = useCodes(user.id);
|
const { codes } = useCodes(user.id);
|
||||||
const {groups} = useGroups(user.id);
|
const { groups } = useGroups(user.id);
|
||||||
|
|
||||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowModal(!!selectedUser && page === "");
|
setShowModal(!!selectedUser && page === "");
|
||||||
}, [selectedUser, page]);
|
}, [selectedUser, page]);
|
||||||
|
|
||||||
const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id);
|
useEffect(() => {
|
||||||
const teacherFilter = (user: User) => user.type === "teacher" && groups.flatMap((g) => g.participants).includes(user.id);
|
// in this case it fetches the master corporate account
|
||||||
|
getUserCorporate(user.id).then(setCorporateUserToShow);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
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 UserDisplay = (displayUser: User) => (
|
const getStatsByStudent = (user: User) =>
|
||||||
<div
|
stats.filter((s) => s.user === user.id);
|
||||||
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 UserDisplay = (displayUser: User) => (
|
||||||
const filter = (x: User) =>
|
<div
|
||||||
x.type === "student" &&
|
onClick={() => setSelectedUser(displayUser)}
|
||||||
(!!selectedUser
|
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"
|
||||||
? groups
|
>
|
||||||
.filter((g) => g.admin === selectedUser.id)
|
<img
|
||||||
.flatMap((g) => g.participants)
|
src={displayUser.profilePicture}
|
||||||
.includes(x.id) || false
|
alt={displayUser.name}
|
||||||
: groups.flatMap((g) => g.participants).includes(x.id));
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
const StudentsList = () => {
|
||||||
<>
|
const filter = (x: User) =>
|
||||||
<div className="flex flex-col gap-4">
|
x.type === "student" &&
|
||||||
<div
|
(!!selectedUser
|
||||||
onClick={() => setPage("")}
|
? groups
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
.filter((g) => g.admin === selectedUser.id)
|
||||||
<BsArrowLeft className="text-xl" />
|
.flatMap((g) => g.participants)
|
||||||
<span>Back</span>
|
.includes(x.id) || false
|
||||||
</div>
|
: groups.flatMap((g) => g.participants).includes(x.id));
|
||||||
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UserList user={user} filters={[filter]} />
|
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 TeachersList = () => {
|
||||||
const filter = (x: User) =>
|
const filter = (x: User) =>
|
||||||
x.type === "teacher" &&
|
x.type === "teacher" &&
|
||||||
(!!selectedUser
|
(!!selectedUser
|
||||||
? groups
|
? groups
|
||||||
.filter((g) => g.admin === selectedUser.id)
|
.filter((g) => g.admin === selectedUser.id)
|
||||||
.flatMap((g) => g.participants)
|
.flatMap((g) => g.participants)
|
||||||
.includes(x.id) || false
|
.includes(x.id) || false
|
||||||
: groups.flatMap((g) => g.participants).includes(x.id));
|
: groups.flatMap((g) => g.participants).includes(x.id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<UserList
|
||||||
<div className="flex flex-col gap-4">
|
user={user}
|
||||||
<div
|
filters={[filter]}
|
||||||
onClick={() => setPage("")}
|
renderHeader={(total) => (
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
<div className="flex flex-col gap-4">
|
||||||
<BsArrowLeft className="text-xl" />
|
<div
|
||||||
<span>Back</span>
|
onClick={() => setPage("")}
|
||||||
</div>
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||||
<h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2>
|
>
|
||||||
</div>
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Teachers ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
<UserList user={user} filters={[filter]} />
|
const GroupsList = () => {
|
||||||
</>
|
const filter = (x: Group) =>
|
||||||
);
|
x.admin === user.id || x.participants.includes(user.id);
|
||||||
};
|
|
||||||
|
|
||||||
const GroupsList = () => {
|
return (
|
||||||
const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id);
|
<>
|
||||||
|
<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 (
|
<GroupList user={user} />
|
||||||
<>
|
</>
|
||||||
<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 averageLevelCalculator = (studentStats: Stat[]) => {
|
const levels: { [key in Module]: number } = {
|
||||||
const formattedStats = studentStats
|
reading: 0,
|
||||||
.map((s) => ({focus: users.find((u) => u.id === s.user)?.focus, score: s.score, module: s.module}))
|
listening: 0,
|
||||||
.filter((f) => !!f.focus);
|
writing: 0,
|
||||||
const bandScores = formattedStats.map((s) => ({
|
speaking: 0,
|
||||||
module: s.module,
|
level: 0,
|
||||||
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
|
};
|
||||||
}));
|
bandScores.forEach((b) => (levels[b.module] += b.level));
|
||||||
|
|
||||||
const levels: {[key in Module]: number} = {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0};
|
return calculateAverageLevel(levels);
|
||||||
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>
|
||||||
|
|
||||||
const DefaultDashboard = () => (
|
<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">
|
||||||
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center">
|
<span className="p-4">Latest students</span>
|
||||||
<IconCard
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
onClick={() => setPage("students")}
|
{users
|
||||||
Icon={BsPersonFill}
|
.filter(studentFilter)
|
||||||
label="Students"
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
value={users.filter(studentFilter).length}
|
.map((x) => (
|
||||||
color="purple"
|
<UserDisplay key={x.id} {...x} />
|
||||||
/>
|
))}
|
||||||
<IconCard
|
</div>
|
||||||
onClick={() => setPage("teachers")}
|
</div>
|
||||||
Icon={BsPencilSquare}
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
label="Teachers"
|
<span className="p-4">Latest teachers</span>
|
||||||
value={users.filter(teacherFilter).length}
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
color="purple"
|
{users
|
||||||
/>
|
.filter(teacherFilter)
|
||||||
<IconCard
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
Icon={BsClipboard2Data}
|
.map((x) => (
|
||||||
label="Exams Performed"
|
<UserDisplay key={x.id} {...x} />
|
||||||
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
|
))}
|
||||||
color="purple"
|
</div>
|
||||||
/>
|
</div>
|
||||||
<IconCard
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
Icon={BsPaperclip}
|
<span className="p-4">Highest level students</span>
|
||||||
label="Average Level"
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
{users
|
||||||
color="purple"
|
.filter(studentFilter)
|
||||||
/>
|
.sort(
|
||||||
<IconCard onClick={() => setPage("groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
|
(a, b) =>
|
||||||
<IconCard
|
calculateAverageLevel(b.levels) -
|
||||||
Icon={BsPersonCheck}
|
calculateAverageLevel(a.levels)
|
||||||
label="User Balance"
|
)
|
||||||
value={`${codes.length}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
|
.map((x) => (
|
||||||
color="purple"
|
<UserDisplay key={x.id} {...x} />
|
||||||
/>
|
))}
|
||||||
<IconCard
|
</div>
|
||||||
Icon={BsClock}
|
</div>
|
||||||
label="Expiration Date"
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
|
<span className="p-4">Highest exam count students</span>
|
||||||
color="rose"
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
/>
|
{users
|
||||||
</section>
|
.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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
return (
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<>
|
||||||
<span className="p-4">Latest students</span>
|
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<>
|
||||||
{users
|
{selectedUser && (
|
||||||
.filter(studentFilter)
|
<div className="w-full flex flex-col gap-8">
|
||||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
<UserCard
|
||||||
.map((x) => (
|
loggedInUser={user}
|
||||||
<UserDisplay key={x.id} {...x} />
|
onClose={(shouldReload) => {
|
||||||
))}
|
setSelectedUser(undefined);
|
||||||
</div>
|
if (shouldReload) reload();
|
||||||
</div>
|
}}
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
onViewStudents={
|
||||||
<span className="p-4">Latest teachers</span>
|
selectedUser.type === "corporate" ||
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
selectedUser.type === "teacher"
|
||||||
{users
|
? () => {
|
||||||
.filter(teacherFilter)
|
appendUserFilters({
|
||||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
id: "view-students",
|
||||||
.map((x) => (
|
filter: (x: User) => x.type === "student",
|
||||||
<UserDisplay key={x.id} {...x} />
|
});
|
||||||
))}
|
appendUserFilters({
|
||||||
</div>
|
id: "belongs-to-admin",
|
||||||
</div>
|
filter: (x: User) =>
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
groups
|
||||||
<span className="p-4">Highest level students</span>
|
.filter(
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
(g) =>
|
||||||
{users
|
g.admin === selectedUser.id ||
|
||||||
.filter(studentFilter)
|
g.participants.includes(selectedUser.id)
|
||||||
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
)
|
||||||
.map((x) => (
|
.flatMap((g) => g.participants)
|
||||||
<UserDisplay key={x.id} {...x} />
|
.includes(x.id),
|
||||||
))}
|
});
|
||||||
</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 (
|
router.push("/list/users");
|
||||||
<>
|
}
|
||||||
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
|
: undefined
|
||||||
<>
|
}
|
||||||
{selectedUser && (
|
onViewTeachers={
|
||||||
<div className="w-full flex flex-col gap-8">
|
selectedUser.type === "corporate" ||
|
||||||
<UserCard
|
selectedUser.type === "student"
|
||||||
loggedInUser={user}
|
? () => {
|
||||||
onClose={(shouldReload) => {
|
appendUserFilters({
|
||||||
setSelectedUser(undefined);
|
id: "view-teachers",
|
||||||
if (shouldReload) reload();
|
filter: (x: User) => x.type === "teacher",
|
||||||
}}
|
});
|
||||||
onViewStudents={
|
appendUserFilters({
|
||||||
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
id: "belongs-to-admin",
|
||||||
? () => {
|
filter: (x: User) =>
|
||||||
appendUserFilters({
|
groups
|
||||||
id: "view-students",
|
.filter(
|
||||||
filter: (x: User) => x.type === "student",
|
(g) =>
|
||||||
});
|
g.admin === selectedUser.id ||
|
||||||
appendUserFilters({
|
g.participants.includes(selectedUser.id)
|
||||||
id: "belongs-to-admin",
|
)
|
||||||
filter: (x: User) =>
|
.flatMap((g) => g.participants)
|
||||||
groups
|
.includes(x.id),
|
||||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
});
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push("/list/users");
|
router.push("/list/users");
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onViewTeachers={
|
user={selectedUser}
|
||||||
selectedUser.type === "corporate" || selectedUser.type === "student"
|
/>
|
||||||
? () => {
|
</div>
|
||||||
appendUserFilters({
|
)}
|
||||||
id: "view-teachers",
|
</>
|
||||||
filter: (x: User) => x.type === "teacher",
|
</Modal>
|
||||||
});
|
{page === "students" && <StudentsList />}
|
||||||
appendUserFilters({
|
{page === "teachers" && <TeachersList />}
|
||||||
id: "belongs-to-admin",
|
{page === "groups" && <GroupsList />}
|
||||||
filter: (x: User) =>
|
{page === "" && <DefaultDashboard />}
|
||||||
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 />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
140
src/dashboards/CorporateStudentsLevels.tsx
Normal file
140
src/dashboards/CorporateStudentsLevels.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import React from "react";
|
||||||
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
import { User } from "@/interfaces/user";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
|
import {
|
||||||
|
BsBook,
|
||||||
|
BsClipboard,
|
||||||
|
BsHeadphones,
|
||||||
|
BsMegaphone,
|
||||||
|
BsPen,
|
||||||
|
} from "react-icons/bs";
|
||||||
|
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
||||||
|
import { capitalize } from "lodash";
|
||||||
|
import { getLevelLabel } from "@/utils/score";
|
||||||
|
|
||||||
|
const Card = ({ user }: { user: User }) => {
|
||||||
|
return (
|
||||||
|
<div className="border-mti-gray-platinum flex flex-col h-fit w-full cursor-pointer 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 CorporateStudentsLevels = () => {
|
||||||
|
const { users } = useUsers();
|
||||||
|
const { groups } = useGroups();
|
||||||
|
|
||||||
|
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 groupsFromCorporate = corporate
|
||||||
|
? groups.filter((g) => g.admin === corporate.id)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
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} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CorporateStudentsLevels;
|
||||||
@@ -4,8 +4,8 @@ import {IconType} from "react-icons";
|
|||||||
interface Props {
|
interface Props {
|
||||||
Icon: IconType;
|
Icon: IconType;
|
||||||
label: string;
|
label: string;
|
||||||
value: string | number;
|
value?: string | number;
|
||||||
color: "purple" | "rose" | "red";
|
color: "purple" | "rose" | "red" | "green";
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,7 @@ export default function IconCard({Icon, label, value, color, tooltip, onClick}:
|
|||||||
purple: "text-mti-purple-light",
|
purple: "text-mti-purple-light",
|
||||||
red: "text-mti-red-light",
|
red: "text-mti-red-light",
|
||||||
rose: "text-mti-rose-light",
|
rose: "text-mti-rose-light",
|
||||||
|
green: "text-mti-green-light",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
424
src/dashboards/MasterCorporate.tsx
Normal file
424
src/dashboards/MasterCorporate.tsx
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
/* 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 />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import InviteCard from "@/components/Medium/InviteCard";
|
import InviteCard from "@/components/Medium/InviteCard";
|
||||||
import PayPalPayment from "@/components/PayPalPayment";
|
|
||||||
import ProfileSummary from "@/components/ProfileSummary";
|
import ProfileSummary from "@/components/ProfileSummary";
|
||||||
import useAssignments from "@/hooks/useAssignments";
|
import useAssignments from "@/hooks/useAssignments";
|
||||||
import useInvites from "@/hooks/useInvites";
|
import useInvites from "@/hooks/useInvites";
|
||||||
@@ -13,7 +12,7 @@ import {CorporateUser, User} from "@/interfaces/user";
|
|||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {getExamById} from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
import {getUserCorporate} from "@/utils/groups";
|
import {getUserCorporate} from "@/utils/groups";
|
||||||
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
|
import {countExamModules, countFullExams, MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
|
||||||
import {getLevelLabel, getLevelScore} from "@/utils/score";
|
import {getLevelLabel, getLevelScore} from "@/utils/score";
|
||||||
import {averageScore, groupBySession} from "@/utils/stats";
|
import {averageScore, groupBySession} from "@/utils/stats";
|
||||||
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
|
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
|
||||||
@@ -35,7 +34,7 @@ interface Props {
|
|||||||
export default function StudentDashboard({user}: Props) {
|
export default function StudentDashboard({user}: Props) {
|
||||||
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
|
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
|
||||||
|
|
||||||
const {stats} = useStats(user.id);
|
const {stats} = useStats(user.id, !user?.id);
|
||||||
const {users} = useUsers();
|
const {users} = useUsers();
|
||||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
|
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
|
||||||
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user.id});
|
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user.id});
|
||||||
@@ -84,16 +83,16 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
user={user}
|
user={user}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
icon: <BsFileEarmarkText className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||||
value: Object.keys(groupBySession(stats)).length,
|
value: countFullExams(stats),
|
||||||
label: "Exams",
|
label: "Exams",
|
||||||
tooltip: "Number of all conducted completed exams",
|
tooltip: "Number of all conducted completed exams",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsPencil className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||||
value: stats.length,
|
value: countExamModules(stats),
|
||||||
label: "Exercises",
|
label: "Modules",
|
||||||
tooltip: "Number of all conducted exercises including Level Test",
|
tooltip: "Number of all exam modules performed including Level Test",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
icon: <BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
|
||||||
|
|||||||
@@ -2,356 +2,477 @@
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useStats from "@/hooks/useStats";
|
import useStats from "@/hooks/useStats";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {CorporateUser, Group, Stat, User} from "@/interfaces/user";
|
import { CorporateUser, Group, Stat, User } from "@/interfaces/user";
|
||||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
import {dateSorter} from "@/utils";
|
import { dateSorter } from "@/utils";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
BsArrowLeft,
|
BsArrowLeft,
|
||||||
BsArrowRepeat,
|
BsArrowRepeat,
|
||||||
BsClipboard2Data,
|
BsClipboard2Data,
|
||||||
BsClipboard2DataFill,
|
BsClipboard2DataFill,
|
||||||
BsClipboard2Heart,
|
BsClipboard2Heart,
|
||||||
BsClipboard2X,
|
BsClipboard2X,
|
||||||
BsClipboardPulse,
|
BsClipboardPulse,
|
||||||
BsClock,
|
BsClock,
|
||||||
BsEnvelopePaper,
|
BsEnvelopePaper,
|
||||||
BsGlobeCentralSouthAsia,
|
BsGlobeCentralSouthAsia,
|
||||||
BsPaperclip,
|
BsPaperclip,
|
||||||
BsPeople,
|
BsPeople,
|
||||||
BsPerson,
|
BsPerson,
|
||||||
BsPersonAdd,
|
BsPersonAdd,
|
||||||
BsPersonFill,
|
BsPersonFill,
|
||||||
BsPersonFillGear,
|
BsPersonFillGear,
|
||||||
BsPersonGear,
|
BsPersonGear,
|
||||||
BsPlus,
|
BsPlus,
|
||||||
BsRepeat,
|
BsRepeat,
|
||||||
BsRepeat1,
|
BsRepeat1,
|
||||||
} from "react-icons/bs";
|
} from "react-icons/bs";
|
||||||
import UserCard from "@/components/UserCard";
|
import UserCard from "@/components/UserCard";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
|
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
|
||||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
||||||
import {Module} from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import {groupByExam} from "@/utils/stats";
|
import { groupByExam } from "@/utils/stats";
|
||||||
import IconCard from "./IconCard";
|
import IconCard from "./IconCard";
|
||||||
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
||||||
import useAssignments from "@/hooks/useAssignments";
|
import useAssignments from "@/hooks/useAssignments";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import { Assignment } from "@/interfaces/results";
|
||||||
import AssignmentCard from "./AssignmentCard";
|
import AssignmentCard from "./AssignmentCard";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import AssignmentCreator from "./AssignmentCreator";
|
import AssignmentCreator from "./AssignmentCreator";
|
||||||
import AssignmentView from "./AssignmentView";
|
import AssignmentView from "./AssignmentView";
|
||||||
import {getUserCorporate} from "@/utils/groups";
|
import { getUserCorporate } from "@/utils/groups";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TeacherDashboard({user}: Props) {
|
export default function TeacherDashboard({ user }: Props) {
|
||||||
const [page, setPage] = useState("");
|
const [page, setPage] = useState("");
|
||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
|
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
|
||||||
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
||||||
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
|
const [corporateUserToShow, setCorporateUserToShow] =
|
||||||
|
useState<CorporateUser>();
|
||||||
|
|
||||||
const {stats} = useStats();
|
const { stats } = useStats();
|
||||||
const {users, reload} = useUsers();
|
const { users, reload } = useUsers();
|
||||||
const {groups} = useGroups(user.id);
|
const { groups } = useGroups(user.id);
|
||||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id});
|
const {
|
||||||
|
assignments,
|
||||||
|
isLoading: isAssignmentsLoading,
|
||||||
|
reload: reloadAssignments,
|
||||||
|
} = useAssignments({ assigner: user.id });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowModal(!!selectedUser && page === "");
|
setShowModal(!!selectedUser && page === "");
|
||||||
}, [selectedUser, page]);
|
}, [selectedUser, page]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getUserCorporate(user.id).then(setCorporateUserToShow);
|
getUserCorporate(user.id).then(setCorporateUserToShow);
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id);
|
const studentFilter = (user: User) =>
|
||||||
|
user.type === "student" &&
|
||||||
|
groups.flatMap((g) => g.participants).includes(user.id);
|
||||||
|
|
||||||
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
const getStatsByStudent = (user: User) =>
|
||||||
|
stats.filter((s) => s.user === user.id);
|
||||||
|
|
||||||
const UserDisplay = (displayUser: User) => (
|
const UserDisplay = (displayUser: User) => (
|
||||||
<div
|
<div
|
||||||
onClick={() => setSelectedUser(displayUser)}
|
onClick={() => setSelectedUser(displayUser)}
|
||||||
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"
|
||||||
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
>
|
||||||
<div className="flex flex-col gap-1 items-start">
|
<img
|
||||||
<span>{displayUser.name}</span>
|
src={displayUser.profilePicture}
|
||||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
alt={displayUser.name}
|
||||||
</div>
|
className="rounded-full w-10 h-10"
|
||||||
</div>
|
/>
|
||||||
);
|
<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 StudentsList = () => {
|
||||||
const filter = (x: User) =>
|
const filter = (x: User) =>
|
||||||
x.type === "student" &&
|
x.type === "student" &&
|
||||||
(!!selectedUser
|
(!!selectedUser
|
||||||
? groups
|
? groups
|
||||||
.filter((g) => g.admin === selectedUser.id)
|
.filter((g) => g.admin === selectedUser.id)
|
||||||
.flatMap((g) => g.participants)
|
.flatMap((g) => g.participants)
|
||||||
.includes(x.id) || false
|
.includes(x.id) || false
|
||||||
: groups.flatMap((g) => g.participants).includes(x.id));
|
: groups.flatMap((g) => g.participants).includes(x.id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<UserList
|
||||||
<div className="flex flex-col gap-4">
|
user={user}
|
||||||
<div
|
filters={[filter]}
|
||||||
onClick={() => setPage("")}
|
renderHeader={(total) => (
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
<div className="flex flex-col gap-4">
|
||||||
<BsArrowLeft className="text-xl" />
|
<div
|
||||||
<span>Back</span>
|
onClick={() => setPage("")}
|
||||||
</div>
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||||
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
|
>
|
||||||
</div>
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Students ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
<UserList user={user} filters={[filter]} />
|
const GroupsList = () => {
|
||||||
</>
|
const filter = (x: Group) =>
|
||||||
);
|
x.admin === user.id || x.participants.includes(user.id);
|
||||||
};
|
|
||||||
|
|
||||||
const GroupsList = () => {
|
return (
|
||||||
const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id);
|
<>
|
||||||
|
<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 (
|
<GroupList user={user} />
|
||||||
<>
|
</>
|
||||||
<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 averageLevelCalculator = (studentStats: Stat[]) => {
|
const levels: { [key in Module]: number } = {
|
||||||
const formattedStats = studentStats
|
reading: 0,
|
||||||
.map((s) => ({focus: users.find((u) => u.id === s.user)?.focus, score: s.score, module: s.module}))
|
listening: 0,
|
||||||
.filter((f) => !!f.focus);
|
writing: 0,
|
||||||
const bandScores = formattedStats.map((s) => ({
|
speaking: 0,
|
||||||
module: s.module,
|
level: 0,
|
||||||
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
|
};
|
||||||
}));
|
bandScores.forEach((b) => (levels[b.module] += b.level));
|
||||||
|
|
||||||
const levels: {[key in Module]: number} = {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0};
|
return calculateAverageLevel(levels);
|
||||||
bandScores.forEach((b) => (levels[b.module] += b.level));
|
};
|
||||||
|
|
||||||
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());
|
||||||
|
|
||||||
const AssignmentsPage = () => {
|
return (
|
||||||
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;
|
<AssignmentView
|
||||||
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
|
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 (
|
const DefaultDashboard = () => (
|
||||||
<>
|
<>
|
||||||
<AssignmentView
|
{corporateUserToShow && (
|
||||||
isOpen={!!selectedAssignment && !isCreatingAssignment}
|
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
|
||||||
onClose={() => {
|
Linked to:{" "}
|
||||||
setSelectedAssignment(undefined);
|
<b>
|
||||||
setIsCreatingAssignment(false);
|
{corporateUserToShow?.corporateInformation?.companyInformation
|
||||||
reloadAssignments();
|
.name || corporateUserToShow.name}
|
||||||
}}
|
</b>
|
||||||
assignment={selectedAssignment}
|
</div>
|
||||||
/>
|
)}
|
||||||
<AssignmentCreator
|
<section
|
||||||
assignment={selectedAssignment}
|
className={clsx(
|
||||||
groups={groups.filter((x) => x.admin === user.id || x.participants.includes(user.id))}
|
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center",
|
||||||
users={users.filter(
|
!!corporateUserToShow && "mt-12 xl:mt-6"
|
||||||
(x) =>
|
)}
|
||||||
x.type === "student" &&
|
>
|
||||||
(!!selectedUser
|
<IconCard
|
||||||
? groups
|
onClick={() => setPage("students")}
|
||||||
.filter((g) => g.admin === selectedUser.id)
|
Icon={BsPersonFill}
|
||||||
.flatMap((g) => g.participants)
|
label="Students"
|
||||||
.includes(x.id) || false
|
value={users.filter(studentFilter).length}
|
||||||
: groups.flatMap((g) => g.participants).includes(x.id)),
|
color="purple"
|
||||||
)}
|
/>
|
||||||
assigner={user.id}
|
<IconCard
|
||||||
isCreating={isCreatingAssignment}
|
Icon={BsClipboard2Data}
|
||||||
cancelCreation={() => {
|
label="Exams Performed"
|
||||||
setIsCreatingAssignment(false);
|
value={
|
||||||
setSelectedAssignment(undefined);
|
stats.filter((s) =>
|
||||||
reloadAssignments();
|
groups.flatMap((g) => g.participants).includes(s.user)
|
||||||
}}
|
).length
|
||||||
/>
|
}
|
||||||
<div className="w-full flex justify-between items-center">
|
color="purple"
|
||||||
<div
|
/>
|
||||||
onClick={() => setPage("")}
|
<IconCard
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
Icon={BsPaperclip}
|
||||||
<BsArrowLeft className="text-xl" />
|
label="Average Level"
|
||||||
<span>Back</span>
|
value={averageLevelCalculator(
|
||||||
</div>
|
stats.filter((s) =>
|
||||||
<div
|
groups.flatMap((g) => g.participants).includes(s.user)
|
||||||
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">
|
).toFixed(1)}
|
||||||
<span>Reload</span>
|
color="purple"
|
||||||
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
|
/>
|
||||||
</div>
|
<IconCard
|
||||||
</div>
|
Icon={BsPeople}
|
||||||
<section className="flex flex-col gap-4">
|
label="Groups"
|
||||||
<h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeFilter).length})</h2>
|
value={groups.length}
|
||||||
<div className="flex flex-wrap gap-2">
|
color="purple"
|
||||||
{assignments.filter(activeFilter).map((a) => (
|
onClick={() => setPage("groups")}
|
||||||
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} />
|
/>
|
||||||
))}
|
<div
|
||||||
</div>
|
onClick={() => setPage("assignments")}
|
||||||
</section>
|
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"
|
||||||
<section className="flex flex-col gap-4">
|
>
|
||||||
<h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureFilter).length})</h2>
|
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
||||||
<div className="flex flex-wrap gap-2">
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
<div
|
<span className="text-lg">Assignments</span>
|
||||||
onClick={() => setIsCreatingAssignment(true)}
|
<span className="font-semibold text-mti-purple-light">
|
||||||
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">
|
{assignments.filter((a) => !a.archived).length}
|
||||||
<BsPlus className="text-6xl" />
|
</span>
|
||||||
<span className="text-lg">New Assignment</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{assignments.filter(futureFilter).map((a) => (
|
</section>
|
||||||
<AssignmentCard
|
|
||||||
{...a}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedAssignment(a);
|
|
||||||
setIsCreatingAssignment(true);
|
|
||||||
}}
|
|
||||||
key={a.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{assignments.filter(pastFilter).map((a) => (
|
|
||||||
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} allowDownload reload={reloadAssignments} allowArchive/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const DefaultDashboard = () => (
|
<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">
|
||||||
{corporateUserToShow && (
|
<span className="p-4">Latest students</span>
|
||||||
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b>
|
{users
|
||||||
</div>
|
.filter(studentFilter)
|
||||||
)}
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
<section
|
.map((x) => (
|
||||||
className={clsx(
|
<UserDisplay key={x.id} {...x} />
|
||||||
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center",
|
))}
|
||||||
!!corporateUserToShow && "mt-12 xl:mt-6",
|
</div>
|
||||||
)}>
|
</div>
|
||||||
<IconCard
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
onClick={() => setPage("students")}
|
<span className="p-4">Highest level students</span>
|
||||||
Icon={BsPersonFill}
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
label="Students"
|
{users
|
||||||
value={users.filter(studentFilter).length}
|
.filter(studentFilter)
|
||||||
color="purple"
|
.sort(
|
||||||
/>
|
(a, b) =>
|
||||||
<IconCard
|
calculateAverageLevel(b.levels) -
|
||||||
Icon={BsClipboard2Data}
|
calculateAverageLevel(a.levels)
|
||||||
label="Exams Performed"
|
)
|
||||||
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
|
.map((x) => (
|
||||||
color="purple"
|
<UserDisplay key={x.id} {...x} />
|
||||||
/>
|
))}
|
||||||
<IconCard
|
</div>
|
||||||
Icon={BsPaperclip}
|
</div>
|
||||||
label="Average Level"
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
<span className="p-4">Highest exam count students</span>
|
||||||
color="purple"
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
/>
|
{users
|
||||||
<IconCard Icon={BsPeople} label="Groups" value={groups.length} color="purple" onClick={() => setPage("groups")} />
|
.filter(studentFilter)
|
||||||
<div
|
.sort(
|
||||||
onClick={() => setPage("assignments")}
|
(a, b) =>
|
||||||
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">
|
Object.keys(groupByExam(getStatsByStudent(b))).length -
|
||||||
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
Object.keys(groupByExam(getStatsByStudent(a))).length
|
||||||
<span className="flex flex-col gap-1 items-center text-xl">
|
)
|
||||||
<span className="text-lg">Assignments</span>
|
.map((x) => (
|
||||||
<span className="font-semibold text-mti-purple-light">{assignments.filter((a) => !a.archived).length}</span>
|
<UserDisplay key={x.id} {...x} />
|
||||||
</span>
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
|
return (
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<>
|
||||||
<span className="p-4">Latest students</span>
|
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
<>
|
||||||
{users
|
{selectedUser && (
|
||||||
.filter(studentFilter)
|
<div className="w-full flex flex-col gap-8">
|
||||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
<UserCard
|
||||||
.map((x) => (
|
loggedInUser={user}
|
||||||
<UserDisplay key={x.id} {...x} />
|
onClose={(shouldReload) => {
|
||||||
))}
|
setSelectedUser(undefined);
|
||||||
</div>
|
if (shouldReload) reload();
|
||||||
</div>
|
}}
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
onViewStudents={
|
||||||
<span className="p-4">Highest level students</span>
|
selectedUser.type === "corporate" ||
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
selectedUser.type === "teacher"
|
||||||
{users
|
? () => setPage("students")
|
||||||
.filter(studentFilter)
|
: undefined
|
||||||
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
}
|
||||||
.map((x) => (
|
onViewTeachers={
|
||||||
<UserDisplay key={x.id} {...x} />
|
selectedUser.type === "corporate"
|
||||||
))}
|
? () => setPage("teachers")
|
||||||
</div>
|
: undefined
|
||||||
</div>
|
}
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
user={selectedUser}
|
||||||
<span className="p-4">Highest exam count students</span>
|
/>
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
</div>
|
||||||
{users
|
)}
|
||||||
.filter(studentFilter)
|
</>
|
||||||
.sort(
|
</Modal>
|
||||||
(a, b) =>
|
{page === "students" && <StudentsList />}
|
||||||
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
{page === "groups" && <GroupsList />}
|
||||||
)
|
{page === "assignments" && <AssignmentsPage />}
|
||||||
.map((x) => (
|
{page === "" && <DefaultDashboard />}
|
||||||
<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" ? () => 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 />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<br />
|
<br />
|
||||||
<p>Don't forget to do it before its end date!</p>
|
<p>Don't forget to do it before its end date!</p>
|
||||||
<p>Click <b><a href="https://platform.encoach.com">here</a></b> to open the EnCoach Platform!</p>
|
<p>Click <b><a href="https://{{environment}}.encoach.com">here</a></b> to open the EnCoach Platform!</p>
|
||||||
<br />
|
<br />
|
||||||
<p>Thanks,</p>
|
<p>Thanks,</p>
|
||||||
<p>Your EnCoach team</p>
|
<p>Your EnCoach team</p>
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
<img src="/logo_title.png" class="w-48 h-48 self-center" />
|
<img src="/logo_title.png" class="w-48 h-48 self-center" />
|
||||||
<div>
|
<div>
|
||||||
<span>Hello future {{type}} of <b>EnCoach</b>,</span><br />
|
<span>Hello future {{type}} of <b>EnCoach</b>,</span><br />
|
||||||
<span>You have been invited to register at <a href="https://platform.encoach.com/register?code={{code}}">EnCoach</a>
|
<span>You have been invited to register at <a
|
||||||
|
href="https://{{environment}}.encoach.com/register?code={{code}}">EnCoach</a>
|
||||||
to
|
to
|
||||||
become a
|
become a
|
||||||
{{type}}!</span><br />
|
{{type}}!</span><br />
|
||||||
@@ -19,7 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
<a href="https://platform.encoach.com/register?code={{code}}"></a>
|
<a href="https://{{environment}}.encoach.com/register?code={{code}}"></a>
|
||||||
<span class="self-center p-4 px-12 text-lg text-[#]" style="background-color: #D5D9F0; color: #353338">
|
<span class="self-center p-4 px-12 text-lg text-[#]" style="background-color: #D5D9F0; color: #353338">
|
||||||
<b>{{code}}</b>
|
<b>{{code}}</b>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
<p>Hello {{name}},</p>
|
<p>Hello {{name}},</p>
|
||||||
<br />
|
<br />
|
||||||
<p>Follow this link to verify your email address.</p>
|
<p>Follow this link to verify your email address.</p>
|
||||||
<a href="https://platform.encoach.com/action?mode=signIn&continueUrl={{email}}&oobCode={{code}}">Verify account</a>
|
<a href="https://{{environment}}.encoach.com/action?mode=signIn&continueUrl={{email}}&oobCode={{code}}">Verify
|
||||||
|
account</a>
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
<p>If you didn’t ask to verify this address, you can ignore this email.</p>
|
<p>If you didn’t ask to verify this address, you can ignore this email.</p>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Tiago Ribeiro",
|
"name": "Tiago Ribeiro",
|
||||||
"email": "tiago.ribeiro@ecrop.dev",
|
"email": "tiago.ribeiro@ecrop.dev",
|
||||||
"code": "123"
|
"code": "123",
|
||||||
|
"environment": "platform"
|
||||||
}
|
}
|
||||||
@@ -9,9 +9,23 @@ import clsx from "clsx";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {Fragment, useEffect, useState} from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs";
|
import {
|
||||||
|
BsArrowCounterclockwise,
|
||||||
|
BsBook,
|
||||||
|
BsClipboard,
|
||||||
|
BsClipboardFill,
|
||||||
|
BsEyeFill,
|
||||||
|
BsHeadphones,
|
||||||
|
BsMegaphone,
|
||||||
|
BsPen,
|
||||||
|
BsShareFill,
|
||||||
|
} from "react-icons/bs";
|
||||||
import {LevelScore} from "@/constants/ielts";
|
import {LevelScore} from "@/constants/ielts";
|
||||||
import {getLevelScore} from "@/utils/score";
|
import {getLevelScore} from "@/utils/score";
|
||||||
|
import {capitalize} from "lodash";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import { UserSolution } from "@/interfaces/exam";
|
||||||
|
import ai_usage from "@/utils/ai.detection";
|
||||||
|
|
||||||
interface Score {
|
interface Score {
|
||||||
module: Module;
|
module: Module;
|
||||||
@@ -24,13 +38,21 @@ interface Props {
|
|||||||
user: User;
|
user: User;
|
||||||
modules: Module[];
|
modules: Module[];
|
||||||
scores: Score[];
|
scores: Score[];
|
||||||
|
information: {
|
||||||
|
timeSpent?: number;
|
||||||
|
inactivity?: number;
|
||||||
|
};
|
||||||
|
solutions: UserSolution[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onViewResults: () => void;
|
onViewResults: (moduleIndex?: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Finish({user, scores, modules, isLoading, onViewResults}: Props) {
|
export default function Finish({user, scores, modules, information, solutions, isLoading, onViewResults}: Props) {
|
||||||
const [selectedModule, setSelectedModule] = useState(modules[0]);
|
const [selectedModule, setSelectedModule] = useState(modules[0]);
|
||||||
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
|
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
|
||||||
|
const [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false);
|
||||||
|
|
||||||
|
const aiUsage = Math.round(ai_usage(solutions) * 100);
|
||||||
|
|
||||||
const exams = useExamStore((state) => state.exams);
|
const exams = useExamStore((state) => state.exams);
|
||||||
|
|
||||||
@@ -61,7 +83,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
|
|
||||||
const getTotalExercises = () => {
|
const getTotalExercises = () => {
|
||||||
const exam = exams.find((x) => x.module === selectedModule)!;
|
const exam = exams.find((x) => x.module === selectedModule)!;
|
||||||
if (exam.module === "reading" || exam.module === "listening") {
|
if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") {
|
||||||
return exam.parts.flatMap((x) => x.exercises).length;
|
return exam.parts.flatMap((x) => x.exercises).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +107,21 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Modal title="Extra Information" isOpen={isExtraInformationOpen} onClose={() => setIsExtraInformationOpen(false)}>
|
||||||
|
<div className="flex flex-col gap-2 mt-4">
|
||||||
|
{!!information.timeSpent && (
|
||||||
|
<span>
|
||||||
|
<b>Time Spent:</b> {Math.floor(information.timeSpent / 60)} minute(s)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!!information.inactivity && (
|
||||||
|
<span>
|
||||||
|
<b>Inactivity:</b> {Math.floor(information.inactivity / 60)} minute(s)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<div className="flex h-fit min-h-full w-full flex-col items-center justify-between gap-8">
|
<div className="flex h-fit min-h-full w-full flex-col items-center justify-between gap-8">
|
||||||
<ModuleTitle
|
<ModuleTitle
|
||||||
module={selectedModule}
|
module={selectedModule}
|
||||||
@@ -93,7 +130,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
minTimer={exams.find((x) => x.module === selectedModule)!.minTimer}
|
minTimer={exams.find((x) => x.module === selectedModule)!.minTimer}
|
||||||
disableTimer
|
disableTimer
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-4 self-start">
|
<div className="flex gap-4 self-start w-full">
|
||||||
{modules.includes("reading") && (
|
{modules.includes("reading") && (
|
||||||
<div
|
<div
|
||||||
onClick={() => setSelectedModule("reading")}
|
onClick={() => setSelectedModule("reading")}
|
||||||
@@ -117,6 +154,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{modules.includes("writing") && (
|
{modules.includes("writing") && (
|
||||||
|
<div className="flex w-full justify-between items-center">
|
||||||
<div
|
<div
|
||||||
onClick={() => setSelectedModule("writing")}
|
onClick={() => setSelectedModule("writing")}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -126,6 +164,18 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
<BsPen className="h-6 w-6" />
|
<BsPen className="h-6 w-6" />
|
||||||
<span className="font-semibold">Writing</span>
|
<span className="font-semibold">Writing</span>
|
||||||
</div>
|
</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") && (
|
{modules.includes("speaking") && (
|
||||||
<div
|
<div
|
||||||
@@ -182,33 +232,37 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
{showLevel(bandScore)}
|
{showLevel(bandScore)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-5">
|
{!["writing", "speaking"].includes(selectedModule) ? (
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col gap-5 w-28">
|
||||||
<div className="bg-mti-red-light mt-1 h-3 w-3 rounded-full" />
|
<div className="flex gap-2">
|
||||||
<div className="flex flex-col">
|
<div className="bg-mti-red-light mt-1 h-3 w-3 rounded-full" />
|
||||||
<span className="text-mti-red-light">
|
<div className="flex flex-col">
|
||||||
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
|
<span className="text-mti-red-light">
|
||||||
</span>
|
{(((selectedScore.total - selectedScore.missing) / selectedScore.total) * 100).toFixed(0)}%
|
||||||
<span className="text-lg">Completion</span>
|
</span>
|
||||||
|
<span className="text-lg">Completion</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="bg-mti-purple-light mt-1 h-3 w-3 rounded-full" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
|
||||||
|
<span className="text-lg">Correct</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="bg-mti-rose-light mt-1 h-3 w-3 rounded-full" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-mti-rose-light">
|
||||||
|
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg">Wrong</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
) : (
|
||||||
<div className="bg-mti-purple-light mt-1 h-3 w-3 rounded-full" />
|
<div className="w-28 h-full" />
|
||||||
<div className="flex flex-col">
|
)}
|
||||||
<span className="text-mti-purple-light">{selectedScore.correct.toString().padStart(2, "0")}</span>
|
|
||||||
<span className="text-lg">Correct</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="bg-mti-rose-light mt-1 h-3 w-3 rounded-full" />
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-mti-rose-light">
|
|
||||||
{(selectedScore.total - selectedScore.correct).toString().padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
<span className="text-lg">Wrong</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -220,6 +274,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
|
disabled={user.type === "admin"}
|
||||||
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||||
<BsArrowCounterclockwise className="h-7 w-7 text-white" />
|
<BsArrowCounterclockwise className="h-7 w-7 text-white" />
|
||||||
</button>
|
</button>
|
||||||
@@ -227,12 +282,30 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={onViewResults}
|
onClick={() => onViewResults()}
|
||||||
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||||
<BsEyeFill className="h-7 w-7 text-white" />
|
<BsEyeFill className="h-7 w-7 text-white" />
|
||||||
</button>
|
</button>
|
||||||
<span>Review Answers</span>
|
<span>Review All</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => onViewResults(modules.findIndex((x) => x === selectedModule))}
|
||||||
|
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||||
|
<BsEyeFill className="h-7 w-7 text-white" />
|
||||||
|
</button>
|
||||||
|
<span>Review {capitalize(selectedModule)}</span>
|
||||||
|
</div>
|
||||||
|
{(!!information.inactivity || !!information.timeSpent) && (
|
||||||
|
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExtraInformationOpen(true)}
|
||||||
|
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||||
|
<BsClipboardFill className="h-7 w-7 text-white" />
|
||||||
|
</button>
|
||||||
|
<span>Extra Information</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link href="/" className="w-full max-w-[200px] self-end">
|
<Link href="/" className="w-full max-w-[200px] self-end">
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
||||||
import {renderExercise} from "@/components/Exercises";
|
import {renderExercise} from "@/components/Exercises";
|
||||||
|
import Button from "@/components/Low/Button";
|
||||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
import {renderSolution} from "@/components/Solutions";
|
import {renderSolution} from "@/components/Solutions";
|
||||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||||
import {LevelExam, UserSolution, WritingExam} from "@/interfaces/exam";
|
import {LevelExam, LevelPart, UserSolution, WritingExam} from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {defaultUserSolutions} from "@/utils/exams";
|
import {defaultUserSolutions} from "@/utils/exams";
|
||||||
import {countExercises} from "@/utils/moduleUtils";
|
import {countExercises} from "@/utils/moduleUtils";
|
||||||
@@ -10,6 +12,7 @@ import {mdiArrowRight} from "@mdi/js";
|
|||||||
import Icon from "@mdi/react";
|
import Icon from "@mdi/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {Fragment, useEffect, useState} from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
|
import {BsChevronDown, BsChevronUp} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -18,84 +21,200 @@ interface Props {
|
|||||||
onFinish: (userSolutions: UserSolution[]) => void;
|
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) {
|
export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
|
||||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
const [showBlankModal, setShowBlankModal] = useState(false);
|
||||||
const [exerciseIndex, setExerciseIndex] = useState(0);
|
|
||||||
|
|
||||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||||
const {hasExamEnded, setHasExamEnded} = 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]);
|
||||||
|
|
||||||
useEffect(() => {
|
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||||
setCurrentQuestionIndex(0);
|
|
||||||
}, [questionIndex]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded && exerciseIndex === -1) {
|
if (hasExamEnded && exerciseIndex === -1) {
|
||||||
setExerciseIndex((prev) => prev + 1);
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
}
|
}
|
||||||
}, [hasExamEnded, exerciseIndex]);
|
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
const confirmFinishModule = (keepGoing?: boolean) => {
|
||||||
if (solution) {
|
if (!keepGoing) {
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
setShowBlankModal(false);
|
||||||
}
|
|
||||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
|
||||||
setExerciseIndex((prev) => prev + 1);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exerciseIndex >= exam.exercises.length) 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);
|
setHasExamEnded(false);
|
||||||
|
|
||||||
if (solution) {
|
if (solution) {
|
||||||
onFinish(
|
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
|
||||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "level", exam: exam.id})),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
onFinish(userSolutions.map((x) => ({...x, module: "level", exam: exam.id})));
|
onFinish(userSolutions);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exerciseIndex > 0) {
|
if (storeQuestionIndex > 0) {
|
||||||
setExerciseIndex((prev) => prev - 1);
|
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 getExercise = () => {
|
||||||
const exercise = exam.exercises[exerciseIndex];
|
const exercise = exam.parts[partIndex].exercises[exerciseIndex];
|
||||||
return {
|
return {
|
||||||
...exercise,
|
...exercise,
|
||||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col h-full w-full gap-8 items-center">
|
<div className="flex flex-col h-full w-full gap-8 items-center">
|
||||||
|
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
||||||
<ModuleTitle
|
<ModuleTitle
|
||||||
minTimer={exam.minTimer}
|
minTimer={exam.minTimer}
|
||||||
exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex}
|
exerciseIndex={calculateExerciseIndex()}
|
||||||
module="level"
|
module="level"
|
||||||
totalExercises={countExercises(exam.exercises)}
|
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||||
disableTimer={showSolutions}
|
disableTimer={showSolutions}
|
||||||
/>
|
/>
|
||||||
{exerciseIndex > -1 &&
|
<div
|
||||||
exerciseIndex < exam.exercises.length &&
|
className={clsx(
|
||||||
!showSolutions &&
|
"mb-20 w-full",
|
||||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
partIndex > -1 && exerciseIndex > -1 && !!exam.parts[partIndex].context && "grid grid-cols-2 gap-4",
|
||||||
{exerciseIndex > -1 &&
|
)}>
|
||||||
exerciseIndex < exam.exercises.length &&
|
{partIndex > -1 && !!exam.parts[partIndex].context && renderText()}
|
||||||
showSolutions &&
|
|
||||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
{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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ListeningExam, UserSolution} from "@/interfaces/exam";
|
import {ListeningExam, MultipleChoiceExercise, UserSolution} from "@/interfaces/exam";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {renderExercise} from "@/components/Exercises";
|
import {renderExercise} from "@/components/Exercises";
|
||||||
import {renderSolution} from "@/components/Solutions";
|
import {renderSolution} from "@/components/Solutions";
|
||||||
@@ -19,15 +19,15 @@ const INSTRUCTIONS_AUDIO_SRC =
|
|||||||
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/generic_listening_intro_v2.mp3?alt=media&token=16769f5f-1e9b-4a72-86a9-45a6f0fa9f82";
|
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/generic_listening_intro_v2.mp3?alt=media&token=16769f5f-1e9b-4a72-86a9-45a6f0fa9f82";
|
||||||
|
|
||||||
export default function Listening({exam, showSolutions = false, onFinish}: Props) {
|
export default function Listening({exam, showSolutions = false, onFinish}: Props) {
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
|
||||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
|
||||||
const [timesListened, setTimesListened] = useState(0);
|
const [timesListened, setTimesListened] = useState(0);
|
||||||
const [showBlankModal, setShowBlankModal] = useState(false);
|
const [showBlankModal, setShowBlankModal] = useState(false);
|
||||||
|
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
|
||||||
|
|
||||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||||
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||||
const {partIndex, setPartIndex} = useExamStore((state) => state);
|
const {partIndex, setPartIndex} = useExamStore((state) => state);
|
||||||
const {exerciseIndex, setExerciseIndex} = 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));
|
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||||
|
|
||||||
@@ -35,9 +35,26 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
if (showSolutions) return setExerciseIndex(-1);
|
if (showSolutions) return setExerciseIndex(-1);
|
||||||
}, [setExerciseIndex, showSolutions]);
|
}, [setExerciseIndex, showSolutions]);
|
||||||
|
|
||||||
// useEffect(() => {
|
useEffect(() => {
|
||||||
// if (exam.variant !== "partial") setPartIndex(-1);
|
if (partIndex === -1 && exam.variant === "partial") {
|
||||||
// }, [exam.variant, setPartIndex]);
|
setPartIndex(0);
|
||||||
|
}
|
||||||
|
}, [partIndex, exam, setPartIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previousParts = exam.parts.filter((_, index) => index < partIndex);
|
||||||
|
let previousMultipleChoice = previousParts.flatMap((x) => x.exercises).filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
|
||||||
|
|
||||||
|
if (partIndex > -1 && exerciseIndex > -1) {
|
||||||
|
const previousPartExercises = exam.parts[partIndex].exercises.filter((_, index) => index < exerciseIndex);
|
||||||
|
const partMultipleChoice = previousPartExercises.filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
|
||||||
|
|
||||||
|
previousMultipleChoice = [...previousMultipleChoice, ...partMultipleChoice];
|
||||||
|
}
|
||||||
|
|
||||||
|
setMultipleChoicesDone(previousMultipleChoice.map((x) => ({id: x.id, amount: x.questions.length - 1})));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded && exerciseIndex === -1) {
|
if (hasExamEnded && exerciseIndex === -1) {
|
||||||
@@ -45,25 +62,25 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
}
|
}
|
||||||
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentQuestionIndex(0);
|
|
||||||
}, [questionIndex]);
|
|
||||||
|
|
||||||
const confirmFinishModule = (keepGoing?: boolean) => {
|
const confirmFinishModule = (keepGoing?: boolean) => {
|
||||||
if (!keepGoing) {
|
if (!keepGoing) {
|
||||||
setShowBlankModal(false);
|
setShowBlankModal(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onFinish(userSolutions.map((x) => ({...x, module: "listening", exam: exam.id})));
|
onFinish(userSolutions);
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
const nextExercise = (solution?: UserSolution) => {
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "listening", exam: exam.id}]);
|
||||||
}
|
}
|
||||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
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) {
|
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||||
setExerciseIndex(exerciseIndex + 1);
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
@@ -72,6 +89,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
|
|
||||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
||||||
setPartIndex(partIndex + 1);
|
setPartIndex(partIndex + 1);
|
||||||
|
setTimesListened(0);
|
||||||
setExerciseIndex(showSolutions ? 0 : -1);
|
setExerciseIndex(showSolutions ? 0 : -1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -91,19 +109,18 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
setHasExamEnded(false);
|
setHasExamEnded(false);
|
||||||
|
|
||||||
if (solution) {
|
if (solution) {
|
||||||
onFinish(
|
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "listening", exam: exam.id}]);
|
||||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "listening", exam: exam.id})),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
onFinish(userSolutions.map((x) => ({...x, module: "listening", exam: exam.id})));
|
onFinish(userSolutions);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "listening", exam: exam.id}]);
|
||||||
}
|
}
|
||||||
|
setStoreQuestionIndex(0);
|
||||||
|
|
||||||
setExerciseIndex(exerciseIndex - 1);
|
setExerciseIndex(exerciseIndex - 1);
|
||||||
};
|
};
|
||||||
@@ -116,6 +133,31 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (partIndex > -1 && exerciseIndex > -1) {
|
||||||
|
const exercise = getExercise();
|
||||||
|
setMultipleChoicesDone((prev) => prev.filter((x) => x.id !== exercise.id));
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [exerciseIndex, partIndex]);
|
||||||
|
|
||||||
|
const calculateExerciseIndex = () => {
|
||||||
|
if (partIndex === -1) return 0;
|
||||||
|
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 renderAudioInstructionsPlayer = () => (
|
const renderAudioInstructionsPlayer = () => (
|
||||||
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
||||||
<div className="flex flex-col w-full gap-2">
|
<div className="flex flex-col w-full gap-2">
|
||||||
@@ -155,18 +197,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
||||||
<div className="flex flex-col h-full w-full gap-8 justify-between">
|
<div className="flex flex-col h-full w-full gap-8 justify-between">
|
||||||
<ModuleTitle
|
<ModuleTitle
|
||||||
exerciseIndex={
|
exerciseIndex={calculateExerciseIndex()}
|
||||||
partIndex === -1
|
|
||||||
? 0
|
|
||||||
: (exam.parts
|
|
||||||
.flatMap((x) => x.exercises)
|
|
||||||
.findIndex(
|
|
||||||
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
|
|
||||||
) || 0) +
|
|
||||||
(exerciseIndex === -1 ? 0 : 1) +
|
|
||||||
questionIndex +
|
|
||||||
currentQuestionIndex
|
|
||||||
}
|
|
||||||
minTimer={exam.minTimer}
|
minTimer={exam.minTimer}
|
||||||
module="listening"
|
module="listening"
|
||||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||||
@@ -183,14 +214,14 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
partIndex > -1 &&
|
partIndex > -1 &&
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||||
!showSolutions &&
|
!showSolutions &&
|
||||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
|
||||||
|
|
||||||
{/* Solution renderer */}
|
{/* Solution renderer */}
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
partIndex > -1 &&
|
partIndex > -1 &&
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||||
showSolutions &&
|
showSolutions &&
|
||||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{exerciseIndex === -1 && partIndex > -1 && exam.variant !== "partial" && (
|
{exerciseIndex === -1 && partIndex > -1 && exam.variant !== "partial" && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ReadingExam, UserSolution} from "@/interfaces/exam";
|
import {MultipleChoiceExercise, ReadingExam, ReadingPart, UserSolution} from "@/interfaces/exam";
|
||||||
import {Fragment, useEffect, useState} from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
import Icon from "@mdi/react";
|
import Icon from "@mdi/react";
|
||||||
import {mdiArrowRight, mdiNotebook} from "@mdi/js";
|
import {mdiArrowRight, mdiNotebook} from "@mdi/js";
|
||||||
@@ -10,7 +10,7 @@ import {renderExercise} from "@/components/Exercises";
|
|||||||
import {renderSolution} from "@/components/Solutions";
|
import {renderSolution} from "@/components/Solutions";
|
||||||
import {Panel} from "primereact/panel";
|
import {Panel} from "primereact/panel";
|
||||||
import {Steps} from "primereact/steps";
|
import {Steps} from "primereact/steps";
|
||||||
import {BsAlarm, BsBook, BsClock, BsStopwatch} from "react-icons/bs";
|
import {BsAlarm, BsBook, BsChevronDown, BsChevronUp, BsClock, BsStopwatch} from "react-icons/bs";
|
||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
import {Divider} from "primereact/divider";
|
import {Divider} from "primereact/divider";
|
||||||
@@ -26,6 +26,8 @@ interface Props {
|
|||||||
onFinish: (userSolutions: UserSolution[]) => void;
|
onFinish: (userSolutions: UserSolution[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const numberToLetter = (number: number) => (number + 9).toString(36).toUpperCase();
|
||||||
|
|
||||||
function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: string; content: string; onClose: () => void}) {
|
function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: string; content: string; onClose: () => void}) {
|
||||||
return (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
@@ -80,17 +82,41 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TextComponent({part, exerciseType}: {part: ReadingPart; exerciseType: string}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
<h3 className="text-xl font-semibold">{part.text.title}</h3>
|
||||||
|
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
||||||
|
{part.text.content
|
||||||
|
.split(/\n|(\\n)/g)
|
||||||
|
.filter((x) => x && x.length > 0 && x !== "\\n")
|
||||||
|
.map((line, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
{exerciseType === "matchSentences" && (
|
||||||
|
<div className="flex gap-3 border border-transparent hover:border-mti-purple-light rounded-lg transition ease-in-out duration-300 p-2 px-3 cursor-pointer">
|
||||||
|
<span className="font-bold text-mti-purple-dark">{numberToLetter(index + 1)}</span>
|
||||||
|
<p>{line}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{exerciseType !== "matchSentences" && <p key={index}>{line}</p>}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Reading({exam, showSolutions = false, onFinish}: Props) {
|
export default function Reading({exam, showSolutions = false, onFinish}: Props) {
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
|
||||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
|
||||||
const [showTextModal, setShowTextModal] = useState(false);
|
const [showTextModal, setShowTextModal] = useState(false);
|
||||||
const [showBlankModal, setShowBlankModal] = useState(false);
|
const [showBlankModal, setShowBlankModal] = useState(false);
|
||||||
|
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
|
||||||
|
const [isTextMinimized, setIsTextMinimzed] = useState(false);
|
||||||
|
const [exerciseType, setExerciseType] = useState("");
|
||||||
|
|
||||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||||
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||||
const {partIndex, setPartIndex} = useExamStore((state) => state);
|
const {partIndex, setPartIndex} = useExamStore((state) => state);
|
||||||
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
||||||
const setStoreQuestionIndex = useExamStore((state) => state.setQuestionIndex);
|
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]);
|
||||||
|
|
||||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||||
|
|
||||||
@@ -98,6 +124,21 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
if (showSolutions) setExerciseIndex(-1);
|
if (showSolutions) setExerciseIndex(-1);
|
||||||
}, [setExerciseIndex, showSolutions]);
|
}, [setExerciseIndex, showSolutions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previousParts = exam.parts.filter((_, index) => index < partIndex);
|
||||||
|
let previousMultipleChoice = previousParts.flatMap((x) => x.exercises).filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
|
||||||
|
|
||||||
|
if (partIndex > -1 && exerciseIndex > -1) {
|
||||||
|
const previousPartExercises = exam.parts[partIndex].exercises.filter((_, index) => index < exerciseIndex);
|
||||||
|
const partMultipleChoice = previousPartExercises.filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
|
||||||
|
|
||||||
|
previousMultipleChoice = [...previousMultipleChoice, ...partMultipleChoice];
|
||||||
|
}
|
||||||
|
|
||||||
|
setMultipleChoicesDone(previousMultipleChoice.map((x) => ({id: x.id, amount: x.questions.length - 1})));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = (e: KeyboardEvent) => {
|
const listener = (e: KeyboardEvent) => {
|
||||||
if (e.key === "F3" || ((e.ctrlKey || e.metaKey) && e.key === "f")) {
|
if (e.key === "F3" || ((e.ctrlKey || e.metaKey) && e.key === "f")) {
|
||||||
@@ -112,10 +153,6 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentQuestionIndex(0);
|
|
||||||
}, [questionIndex]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded && exerciseIndex === -1) {
|
if (hasExamEnded && exerciseIndex === -1) {
|
||||||
setExerciseIndex(exerciseIndex + 1);
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
@@ -128,15 +165,19 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onFinish(userSolutions.map((x) => ({...x, module: "reading", exam: exam.id})));
|
onFinish(userSolutions);
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
const nextExercise = (solution?: UserSolution) => {
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "reading", exam: exam.id}]);
|
||||||
|
}
|
||||||
|
if (storeQuestionIndex > 0) {
|
||||||
|
const exercise = getExercise();
|
||||||
|
setExerciseType(exercise.type);
|
||||||
|
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: storeQuestionIndex}]);
|
||||||
}
|
}
|
||||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
|
||||||
setStoreQuestionIndex(0);
|
setStoreQuestionIndex(0);
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||||
@@ -165,18 +206,16 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
setHasExamEnded(false);
|
setHasExamEnded(false);
|
||||||
|
|
||||||
if (solution) {
|
if (solution) {
|
||||||
onFinish(
|
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "reading", exam: exam.id}]);
|
||||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "reading", exam: exam.id})),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
onFinish(userSolutions.map((x) => ({...x, module: "reading", exam: exam.id})));
|
onFinish(userSolutions);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "reading", exam: exam.id}]);
|
||||||
}
|
}
|
||||||
setStoreQuestionIndex(0);
|
setStoreQuestionIndex(0);
|
||||||
|
|
||||||
@@ -191,23 +230,56 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (partIndex > -1 && exerciseIndex > -1) {
|
||||||
|
const exercise = getExercise();
|
||||||
|
setExerciseType(exercise.type);
|
||||||
|
setMultipleChoicesDone((prev) => prev.filter((x) => x.id !== exercise.id));
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [exerciseIndex, partIndex]);
|
||||||
|
|
||||||
|
const calculateExerciseIndex = () => {
|
||||||
|
if (partIndex === -1) return 0;
|
||||||
|
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 = () => (
|
const renderText = () => (
|
||||||
<div className="flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16 mt-4">
|
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative", isTextMinimized ? "p-2 px-8" : "py-8 px-16")}>
|
||||||
<div className="flex flex-col w-full gap-2">
|
<button
|
||||||
<h4 className="text-xl font-semibold">
|
data-tip={isTextMinimized ? "Maximise" : "Minimize"}
|
||||||
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
className={clsx("absolute right-8 tooltip", isTextMinimized ? "top-1/2 -translate-y-1/2" : "top-8")}
|
||||||
</h4>
|
onClick={() => setIsTextMinimzed((prev) => !prev)}>
|
||||||
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
|
{isTextMinimized ? (
|
||||||
</div>
|
<BsChevronDown className="text-mti-purple-dark text-lg" />
|
||||||
<div className="flex flex-col gap-2 w-full">
|
) : (
|
||||||
<h3 className="text-xl font-semibold">{exam.parts[partIndex].text.title}</h3>
|
<BsChevronUp className="text-mti-purple-dark text-lg" />
|
||||||
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
)}
|
||||||
<span className="overflow-auto">
|
</button>
|
||||||
{exam.parts[partIndex].text.content.split("\\n").map((line, index) => (
|
{!isTextMinimized && (
|
||||||
<p key={index}>{line}</p>
|
<>
|
||||||
))}
|
<div className="flex flex-col w-full gap-2">
|
||||||
</span>
|
<h4 className="text-xl font-semibold">
|
||||||
</div>
|
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]} exerciseType={exerciseType} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isTextMinimized && <span className="font-semibold">Reading Passage</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -218,38 +290,31 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
|||||||
{partIndex > -1 && <TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />}
|
{partIndex > -1 && <TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />}
|
||||||
<ModuleTitle
|
<ModuleTitle
|
||||||
minTimer={exam.minTimer}
|
minTimer={exam.minTimer}
|
||||||
exerciseIndex={
|
exerciseIndex={calculateExerciseIndex()}
|
||||||
(exam.parts
|
|
||||||
.flatMap((x) => x.exercises)
|
|
||||||
.findIndex(
|
|
||||||
(x) =>
|
|
||||||
x.id ===
|
|
||||||
exam.parts[partIndex > -1 ? partIndex : 0].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]
|
|
||||||
?.id,
|
|
||||||
) || 0) +
|
|
||||||
(exerciseIndex === -1 ? 0 : 1) +
|
|
||||||
questionIndex +
|
|
||||||
currentQuestionIndex
|
|
||||||
}
|
|
||||||
module="reading"
|
module="reading"
|
||||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||||
disableTimer={showSolutions}
|
disableTimer={showSolutions}
|
||||||
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
|
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
|
||||||
/>
|
/>
|
||||||
<div className={clsx("mb-20 w-full", exerciseIndex > -1 && "grid grid-cols-2 gap-4")}>
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"mb-20 w-full",
|
||||||
|
exerciseIndex > -1 && !isTextMinimized && "grid grid-cols-2 gap-4",
|
||||||
|
exerciseIndex > -1 && isTextMinimized && "flex flex-col gap-2",
|
||||||
|
)}>
|
||||||
{partIndex > -1 && renderText()}
|
{partIndex > -1 && renderText()}
|
||||||
|
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
partIndex > -1 &&
|
partIndex > -1 &&
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||||
!showSolutions &&
|
!showSolutions &&
|
||||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
|
||||||
|
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
partIndex > -1 &&
|
partIndex > -1 &&
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||||
showSolutions &&
|
showSolutions &&
|
||||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||||
</div>
|
</div>
|
||||||
{exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
|
{exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,310 +1,442 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {useState} from "react";
|
import { useState } from "react";
|
||||||
import {Module} from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {User} from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
import {
|
||||||
import {totalExamsByModule} from "@/utils/stats";
|
BsArrowRepeat,
|
||||||
|
BsBook,
|
||||||
|
BsCheck,
|
||||||
|
BsCheckCircle,
|
||||||
|
BsClipboard,
|
||||||
|
BsHeadphones,
|
||||||
|
BsMegaphone,
|
||||||
|
BsPen,
|
||||||
|
BsXCircle,
|
||||||
|
} from "react-icons/bs";
|
||||||
|
import { totalExamsByModule } from "@/utils/stats";
|
||||||
import useStats from "@/hooks/useStats";
|
import useStats from "@/hooks/useStats";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import {calculateAverageLevel} from "@/utils/score";
|
import { calculateAverageLevel } from "@/utils/score";
|
||||||
import {sortByModuleName} from "@/utils/moduleUtils";
|
import { sortByModuleName } from "@/utils/moduleUtils";
|
||||||
import {capitalize} from "lodash";
|
import { capitalize } from "lodash";
|
||||||
import ProfileSummary from "@/components/ProfileSummary";
|
import ProfileSummary from "@/components/ProfileSummary";
|
||||||
import {Variant} from "@/interfaces/exam";
|
import { Variant } from "@/interfaces/exam";
|
||||||
import useSessions, {Session} from "@/hooks/useSessions";
|
import useSessions, { Session } from "@/hooks/useSessions";
|
||||||
import SessionCard from "@/components/Medium/SessionCard";
|
import SessionCard from "@/components/Medium/SessionCard";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
page: "exercises" | "exams";
|
page: "exercises" | "exams";
|
||||||
onStart: (modules: Module[], avoidRepeated: boolean, variant: Variant) => void;
|
onStart: (
|
||||||
disableSelection?: boolean;
|
modules: Module[],
|
||||||
|
avoidRepeated: boolean,
|
||||||
|
variant: Variant,
|
||||||
|
) => void;
|
||||||
|
disableSelection?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Selection({user, page, onStart, disableSelection = false}: Props) {
|
export default function Selection({
|
||||||
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
|
user,
|
||||||
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
|
page,
|
||||||
const [variant, setVariant] = useState<Variant>("full");
|
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 { stats } = useStats(user?.id);
|
||||||
const {sessions, isLoading, reload} = useSessions(user.id);
|
const { sessions, isLoading, reload } = useSessions(user.id);
|
||||||
|
|
||||||
const state = useExamStore((state) => state);
|
const state = useExamStore((state) => state);
|
||||||
|
|
||||||
const toggleModule = (module: Module) => {
|
const toggleModule = (module: Module) => {
|
||||||
const modules = selectedModules.filter((x) => x !== module);
|
const modules = selectedModules.filter((x) => x !== module);
|
||||||
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
|
setSelectedModules((prev) =>
|
||||||
};
|
prev.includes(module) ? modules : [...modules, module],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const loadSession = async (session: Session) => {
|
const loadSession = async (session: Session) => {
|
||||||
state.setSelectedModules(session.selectedModules);
|
state.setSelectedModules(session.selectedModules);
|
||||||
state.setExam(session.exam);
|
state.setExam(session.exam);
|
||||||
state.setExams(session.exams);
|
state.setExams(session.exams);
|
||||||
state.setSessionId(session.sessionId);
|
state.setSessionId(session.sessionId);
|
||||||
state.setAssignment(session.assignment);
|
state.setAssignment(session.assignment);
|
||||||
state.setExerciseIndex(session.exerciseIndex);
|
state.setExerciseIndex(session.exerciseIndex);
|
||||||
state.setPartIndex(session.partIndex);
|
state.setPartIndex(session.partIndex);
|
||||||
state.setModuleIndex(session.moduleIndex);
|
state.setModuleIndex(session.moduleIndex);
|
||||||
state.setTimeSpent(session.timeSpent);
|
state.setTimeSpent(session.timeSpent);
|
||||||
state.setUserSolutions(session.userSolutions);
|
state.setUserSolutions(session.userSolutions);
|
||||||
state.setShowSolutions(false);
|
state.setShowSolutions(false);
|
||||||
state.setQuestionIndex(session.questionIndex);
|
state.setQuestionIndex(session.questionIndex);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative flex h-full w-full flex-col gap-8 md:gap-16">
|
<div className="relative flex h-full w-full flex-col gap-8 md:gap-16">
|
||||||
{user && (
|
{user && (
|
||||||
<ProfileSummary
|
<ProfileSummary
|
||||||
user={user}
|
user={user}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
icon: <BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />,
|
icon: (
|
||||||
label: "Reading",
|
<BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />
|
||||||
value: totalExamsByModule(stats, "reading"),
|
),
|
||||||
tooltip: "The amount of reading exams performed.",
|
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"),
|
icon: (
|
||||||
tooltip: "The amount of listening exams performed.",
|
<BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />
|
||||||
},
|
),
|
||||||
{
|
label: "Listening",
|
||||||
icon: <BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />,
|
value: totalExamsByModule(stats, "listening"),
|
||||||
label: "Writing",
|
tooltip: "The amount of listening exams performed.",
|
||||||
value: totalExamsByModule(stats, "writing"),
|
},
|
||||||
tooltip: "The amount of writing exams performed.",
|
{
|
||||||
},
|
icon: (
|
||||||
{
|
<BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />
|
||||||
icon: <BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />,
|
),
|
||||||
label: "Speaking",
|
label: "Writing",
|
||||||
value: totalExamsByModule(stats, "speaking"),
|
value: totalExamsByModule(stats, "writing"),
|
||||||
tooltip: "The amount of speaking exams performed.",
|
tooltip: "The amount of writing exams performed.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />,
|
icon: (
|
||||||
label: "Level",
|
<BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />
|
||||||
value: totalExamsByModule(stats, "level"),
|
),
|
||||||
tooltip: "The amount of level exams performed.",
|
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">
|
<section className="flex flex-col gap-3">
|
||||||
<span className="text-lg font-bold">About {capitalize(page)}</span>
|
<span className="text-lg font-bold">About {capitalize(page)}</span>
|
||||||
<span className="text-mti-gray-taupe">
|
<span className="text-mti-gray-taupe">
|
||||||
{page === "exercises" && (
|
{page === "exercises" && (
|
||||||
<>
|
<>
|
||||||
In the realm of language acquisition, practice makes perfect, and our exercises are the key to unlocking your full
|
In the realm of language acquisition, practice makes perfect,
|
||||||
potential. Dive into a world of interactive and engaging exercises that cater to diverse learning styles. From grammar
|
and our exercises are the key to unlocking your full potential.
|
||||||
drills that build a strong foundation to vocabulary challenges that broaden your lexicon, our exercises are carefully
|
Dive into a world of interactive and engaging exercises that
|
||||||
designed to make learning English both enjoyable and effective. Whether you're looking to reinforce specific
|
cater to diverse learning styles. From grammar drills that build
|
||||||
skills or embark on a holistic language journey, our exercises are your companions in the pursuit of excellence.
|
a strong foundation to vocabulary challenges that broaden your
|
||||||
Embrace the joy of learning as you navigate through a variety of activities that cater to every facet of language
|
lexicon, our exercises are carefully designed to make learning
|
||||||
acquisition. Your linguistic adventure starts here!
|
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
|
||||||
{page === "exams" && (
|
pursuit of excellence. Embrace the joy of learning as you
|
||||||
<>
|
navigate through a variety of activities that cater to every
|
||||||
Welcome to the heart of success on your English language journey! Our exams are crafted with precision to assess and
|
facet of language acquisition. Your linguistic adventure starts
|
||||||
enhance your language skills. Each test is a passport to your linguistic prowess, designed to challenge and elevate
|
here!
|
||||||
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
|
{page === "exams" && (
|
||||||
destination; it's a testament to your dedication and our commitment to empowering you with the English language.
|
<>
|
||||||
</>
|
Welcome to the heart of success on your English language
|
||||||
)}
|
journey! Our exams are crafted with precision to assess and
|
||||||
</span>
|
enhance your language skills. Each test is a passport to your
|
||||||
</section>
|
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 && (
|
{sessions.length > 0 && (
|
||||||
<section className="flex flex-col gap-3 md:gap-3">
|
<section className="flex flex-col gap-3 md:gap-3">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={reload}
|
onClick={reload}
|
||||||
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
|
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"
|
||||||
<span className="text-mti-black text-lg font-bold">Unfinished Sessions</span>
|
>
|
||||||
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
|
<span className="text-mti-black text-lg font-bold">
|
||||||
</div>
|
Unfinished Sessions
|
||||||
</div>
|
</span>
|
||||||
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
<BsArrowRepeat
|
||||||
{sessions
|
className={clsx("text-xl", isLoading && "animate-spin")}
|
||||||
.sort((a, b) => moment(b.date).diff(moment(a.date)))
|
/>
|
||||||
.map((session) => (
|
</div>
|
||||||
<SessionCard session={session} key={session.sessionId} reload={reload} loadSession={loadSession} />
|
</div>
|
||||||
))}
|
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||||
</span>
|
{sessions
|
||||||
</section>
|
.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">
|
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
|
||||||
<div
|
<div
|
||||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
|
onClick={
|
||||||
className={clsx(
|
!disableSelection && !selectedModules.includes("level")
|
||||||
"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",
|
? () => toggleModule("reading")
|
||||||
selectedModules.includes("reading") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
: undefined
|
||||||
)}>
|
}
|
||||||
<div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
className={clsx(
|
||||||
<BsBook className="h-7 w-7 text-white" />
|
"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",
|
||||||
</div>
|
selectedModules.includes("reading") || disableSelection
|
||||||
<span className="font-semibold">Reading:</span>
|
? "border-mti-purple-light"
|
||||||
<p className="text-left text-xs">
|
: "border-mti-gray-platinum",
|
||||||
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="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
<BsBook className="h-7 w-7 text-white" />
|
||||||
)}
|
</div>
|
||||||
{(selectedModules.includes("reading") || disableSelection) && (
|
<span className="font-semibold">Reading:</span>
|
||||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
<p className="text-left text-xs">
|
||||||
)}
|
Expand your vocabulary, improve your reading comprehension and
|
||||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
improve your ability to interpret texts in English.
|
||||||
</div>
|
</p>
|
||||||
<div
|
{!selectedModules.includes("reading") &&
|
||||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
|
!selectedModules.includes("level") &&
|
||||||
className={clsx(
|
!disableSelection && (
|
||||||
"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",
|
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||||
selectedModules.includes("listening") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
)}
|
||||||
)}>
|
{(selectedModules.includes("reading") || disableSelection) && (
|
||||||
<div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||||
<BsHeadphones className="h-7 w-7 text-white" />
|
)}
|
||||||
</div>
|
{selectedModules.includes("level") && (
|
||||||
<span className="font-semibold">Listening:</span>
|
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||||
<p className="text-left text-xs">
|
)}
|
||||||
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
|
</div>
|
||||||
</p>
|
<div
|
||||||
{!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && (
|
onClick={
|
||||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
!disableSelection && !selectedModules.includes("level")
|
||||||
)}
|
? () => toggleModule("listening")
|
||||||
{(selectedModules.includes("listening") || disableSelection) && (
|
: undefined
|
||||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
}
|
||||||
)}
|
className={clsx(
|
||||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
"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",
|
||||||
</div>
|
selectedModules.includes("listening") || disableSelection
|
||||||
<div
|
? "border-mti-purple-light"
|
||||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
|
: "border-mti-gray-platinum",
|
||||||
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-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 className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
</div>
|
||||||
<BsPen className="h-7 w-7 text-white" />
|
<span className="font-semibold">Listening:</span>
|
||||||
</div>
|
<p className="text-left text-xs">
|
||||||
<span className="font-semibold">Writing:</span>
|
Improve your ability to follow conversations in English and your
|
||||||
<p className="text-left text-xs">
|
ability to understand different accents and intonations.
|
||||||
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
|
</p>
|
||||||
</p>
|
{!selectedModules.includes("listening") &&
|
||||||
{!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && (
|
!selectedModules.includes("level") &&
|
||||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
!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("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>
|
{selectedModules.includes("level") && (
|
||||||
<div
|
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||||
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
|
)}
|
||||||
className={clsx(
|
</div>
|
||||||
"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",
|
<div
|
||||||
selectedModules.includes("speaking") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
onClick={
|
||||||
)}>
|
!disableSelection && !selectedModules.includes("level")
|
||||||
<div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
? () => toggleModule("writing")
|
||||||
<BsMegaphone className="h-7 w-7 text-white" />
|
: undefined
|
||||||
</div>
|
}
|
||||||
<span className="font-semibold">Speaking:</span>
|
className={clsx(
|
||||||
<p className="text-left text-xs">
|
"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",
|
||||||
You'll have access to interactive dialogs, pronunciation exercises and speech recordings.
|
selectedModules.includes("writing") || disableSelection
|
||||||
</p>
|
? "border-mti-purple-light"
|
||||||
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && (
|
: "border-mti-gray-platinum",
|
||||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
)}
|
||||||
)}
|
>
|
||||||
{(selectedModules.includes("speaking") || disableSelection) && (
|
<div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
<BsPen className="h-7 w-7 text-white" />
|
||||||
)}
|
</div>
|
||||||
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
|
<span className="font-semibold">Writing:</span>
|
||||||
</div>
|
<p className="text-left text-xs">
|
||||||
{!disableSelection && (
|
Allow you to practice writing in a variety of formats, from simple
|
||||||
<div
|
paragraphs to complex essays.
|
||||||
onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
|
</p>
|
||||||
className={clsx(
|
{!selectedModules.includes("writing") &&
|
||||||
"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") &&
|
||||||
selectedModules.includes("level") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
|
!disableSelection && (
|
||||||
)}>
|
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||||
<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" />
|
{(selectedModules.includes("writing") || disableSelection) && (
|
||||||
</div>
|
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||||
<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.includes("level") && selectedModules.length === 0 && !disableSelection && (
|
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||||
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
)}
|
||||||
)}
|
</div>
|
||||||
{(selectedModules.includes("level") || disableSelection) && (
|
<div
|
||||||
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
onClick={
|
||||||
)}
|
!disableSelection && !selectedModules.includes("level")
|
||||||
{!selectedModules.includes("level") && selectedModules.length > 0 && (
|
? () => toggleModule("speaking")
|
||||||
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
: undefined
|
||||||
)}
|
}
|
||||||
</div>
|
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",
|
||||||
</section>
|
selectedModules.includes("speaking") || disableSelection
|
||||||
<div className="-md:flex-col -md:gap-4 -md:justify-center flex w-full items-center md:justify-between">
|
? "border-mti-purple-light"
|
||||||
<div className="flex w-full flex-col items-center gap-3">
|
: "border-mti-gray-platinum",
|
||||||
<div
|
)}
|
||||||
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
|
>
|
||||||
onClick={() => setAvoidRepeatedExams((prev) => !prev)}>
|
<div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||||
<input type="checkbox" className="hidden" />
|
<BsMegaphone className="h-7 w-7 text-white" />
|
||||||
<div
|
</div>
|
||||||
className={clsx(
|
<span className="font-semibold">Speaking:</span>
|
||||||
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
<p className="text-left text-xs">
|
||||||
"transition duration-300 ease-in-out",
|
You'll have access to interactive dialogs, pronunciation
|
||||||
avoidRepeatedExams && "!bg-mti-purple-light ",
|
exercises and speech recordings.
|
||||||
)}>
|
</p>
|
||||||
<BsCheck color="white" className="h-full w-full" />
|
{!selectedModules.includes("speaking") &&
|
||||||
</div>
|
!selectedModules.includes("level") &&
|
||||||
<span className="tooltip" data-tip="If possible, the platform will choose exams not yet done.">
|
!disableSelection && (
|
||||||
Avoid Repeated Questions
|
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||||
</span>
|
)}
|
||||||
</div>
|
{(selectedModules.includes("speaking") || disableSelection) && (
|
||||||
<div
|
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
|
||||||
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"))}>
|
{selectedModules.includes("level") && (
|
||||||
<input type="checkbox" className="hidden" />
|
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
|
||||||
<div
|
)}
|
||||||
className={clsx(
|
</div>
|
||||||
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
|
{!disableSelection && (
|
||||||
"transition duration-300 ease-in-out",
|
<div
|
||||||
variant === "full" && "!bg-mti-purple-light ",
|
onClick={
|
||||||
)}>
|
selectedModules.length === 0 ||
|
||||||
<BsCheck color="white" className="h-full w-full" />
|
selectedModules.includes("level")
|
||||||
</div>
|
? () => toggleModule("level")
|
||||||
<span>Full length exams</span>
|
: undefined
|
||||||
</div>
|
}
|
||||||
</div>
|
className={clsx(
|
||||||
<div className="tooltip w-full" data-tip={`Your screen size is too small to do ${page}`}>
|
"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",
|
||||||
<Button color="purple" className="w-full max-w-xs px-12 md:hidden" disabled>
|
selectedModules.includes("level") || disableSelection
|
||||||
Start Exam
|
? "border-mti-purple-light"
|
||||||
</Button>
|
: "border-mti-gray-platinum",
|
||||||
</div>
|
)}
|
||||||
<Button
|
>
|
||||||
onClick={() =>
|
<div className="bg-ielts-level absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
|
||||||
onStart(
|
<BsClipboard className="h-7 w-7 text-white" />
|
||||||
!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"],
|
</div>
|
||||||
avoidRepeatedExams,
|
<span className="font-semibold">Level:</span>
|
||||||
variant,
|
<p className="text-left text-xs">
|
||||||
)
|
You'll be able to test your english level with multiple
|
||||||
}
|
choice questions.
|
||||||
color="purple"
|
</p>
|
||||||
className="-md:hidden w-full max-w-xs px-12 md:self-end"
|
{!selectedModules.includes("level") &&
|
||||||
disabled={selectedModules.length === 0 && !disableSelection}>
|
selectedModules.length === 0 &&
|
||||||
Start Exam
|
!disableSelection && (
|
||||||
</Button>
|
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
|
||||||
</div>
|
)}
|
||||||
</div>
|
{(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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {renderExercise} from "@/components/Exercises";
|
|||||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
import {renderSolution} from "@/components/Solutions";
|
import {renderSolution} from "@/components/Solutions";
|
||||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||||
import {UserSolution, SpeakingExam} from "@/interfaces/exam";
|
import {UserSolution, SpeakingExam, SpeakingExercise, InteractiveSpeakingExercise} from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {defaultUserSolutions} from "@/utils/exams";
|
import {defaultUserSolutions} from "@/utils/exams";
|
||||||
import {countExercises} from "@/utils/moduleUtils";
|
import {countExercises} from "@/utils/moduleUtils";
|
||||||
@@ -20,25 +20,26 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Speaking({exam, showSolutions = false, onFinish}: Props) {
|
export default function Speaking({exam, showSolutions = false, onFinish}: Props) {
|
||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
const [speakingPromptsDone, setSpeakingPromptsDone] = useState<{id: string; amount: number}[]>([]);
|
||||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
|
||||||
|
|
||||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||||
|
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||||
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||||
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
||||||
|
|
||||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentQuestionIndex(0);
|
|
||||||
}, [questionIndex]);
|
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
const nextExercise = (solution?: UserSolution) => {
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]);
|
||||||
}
|
}
|
||||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
|
||||||
|
if (questionIndex > 0) {
|
||||||
|
const exercise = getExercise();
|
||||||
|
setSpeakingPromptsDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: questionIndex}]);
|
||||||
|
}
|
||||||
|
setQuestionIndex(0);
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
if (exerciseIndex + 1 < exam.exercises.length) {
|
||||||
setExerciseIndex(exerciseIndex + 1);
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
@@ -50,18 +51,16 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
|||||||
setHasExamEnded(false);
|
setHasExamEnded(false);
|
||||||
|
|
||||||
if (solution) {
|
if (solution) {
|
||||||
onFinish(
|
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]);
|
||||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "speaking", exam: exam.id})),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
onFinish(userSolutions.map((x) => ({...x, module: "speaking", exam: exam.id})));
|
onFinish(userSolutions);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exerciseIndex > 0) {
|
if (exerciseIndex > 0) {
|
||||||
@@ -73,8 +72,9 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
|||||||
const exercise = exam.exercises[exerciseIndex];
|
const exercise = exam.exercises[exerciseIndex];
|
||||||
return {
|
return {
|
||||||
...exercise,
|
...exercise,
|
||||||
|
variant: exerciseIndex < 2 && exercise.type === "interactiveSpeaking" ? "initial" : undefined,
|
||||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||||
};
|
} as SpeakingExercise | InteractiveSpeakingExercise;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -83,7 +83,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
|||||||
<ModuleTitle
|
<ModuleTitle
|
||||||
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
|
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
|
||||||
minTimer={exam.minTimer}
|
minTimer={exam.minTimer}
|
||||||
exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex}
|
exerciseIndex={exerciseIndex + 1 + questionIndex + speakingPromptsDone.reduce((acc, curr) => acc + curr.amount, 0)}
|
||||||
module="speaking"
|
module="speaking"
|
||||||
totalExercises={countExercises(exam.exercises)}
|
totalExercises={countExercises(exam.exercises)}
|
||||||
disableTimer={showSolutions}
|
disableTimer={showSolutions}
|
||||||
@@ -91,11 +91,11 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
|||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
!showSolutions &&
|
!showSolutions &&
|
||||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
|
||||||
{exerciseIndex > -1 &&
|
{exerciseIndex > -1 &&
|
||||||
exerciseIndex < exam.exercises.length &&
|
exerciseIndex < exam.exercises.length &&
|
||||||
showSolutions &&
|
showSolutions &&
|
||||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
|
|||||||
const nextExercise = (solution?: UserSolution) => {
|
const nextExercise = (solution?: UserSolution) => {
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "writing", exam: exam.id}]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
if (exerciseIndex + 1 < exam.exercises.length) {
|
||||||
@@ -41,18 +41,16 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
|
|||||||
setHasExamEnded(false);
|
setHasExamEnded(false);
|
||||||
|
|
||||||
if (solution) {
|
if (solution) {
|
||||||
onFinish(
|
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "writing", exam: exam.id}]);
|
||||||
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "writing", exam: exam.id})),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
onFinish(userSolutions.map((x) => ({...x, module: "writing", exam: exam.id})));
|
onFinish(userSolutions);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), solution]);
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "writing", exam: exam.id}]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exerciseIndex > 0) {
|
if (exerciseIndex > 0) {
|
||||||
|
|||||||
@@ -25,7 +25,13 @@ const thresholds = [
|
|||||||
level: "High A2/Low B1",
|
level: "High A2/Low B1",
|
||||||
label: "Pre-Intermediate",
|
label: "Pre-Intermediate",
|
||||||
minValue: 8,
|
minValue: 8,
|
||||||
maxValue: 12,
|
maxValue: 11,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
level: "High B1/Low B2",
|
||||||
|
label: "Intermediate",
|
||||||
|
minValue: 12,
|
||||||
|
maxValue: 15,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
level: "High B2/Low C1",
|
level: "High B2/Low C1",
|
||||||
|
|||||||
@@ -4,14 +4,18 @@ import React from "react";
|
|||||||
import {View, Text, Image} from "@react-pdf/renderer";
|
import {View, Text, Image} from "@react-pdf/renderer";
|
||||||
import {styles} from "../styles";
|
import {styles} from "../styles";
|
||||||
import {ModuleScore} from "@/interfaces/module.scores";
|
import {ModuleScore} from "@/interfaces/module.scores";
|
||||||
|
import {calculateBandScore} from "@/utils/score";
|
||||||
|
import {Module} from "@/interfaces";
|
||||||
|
|
||||||
export const RadialResult = ({module, score, total, png}: ModuleScore) => (
|
export const RadialResult = ({module, score, total, png}: ModuleScore) => (
|
||||||
<View style={[styles.textFont, styles.radialContainer]}>
|
<View style={[styles.textFont, styles.radialContainer]}>
|
||||||
<Text style={[styles.textColor, styles.textBold, {fontSize: 10}]}>{module}</Text>
|
<Text style={[styles.textColor, styles.textBold, {fontSize: 10}]}>{module}</Text>
|
||||||
<Image src={png} style={styles.image64}></Image>
|
<Image src={png} style={styles.image64}></Image>
|
||||||
<View style={[styles.textColor, styles.radialResultContainer]}>
|
<View style={[styles.textColor, styles.radialResultContainer]}>
|
||||||
<Text style={styles.textBold}>{score.toFixed(2)}</Text>
|
<Text style={styles.textBold}>
|
||||||
<Text style={{fontSize: 8}}>out of {total}</Text>
|
{module === "level" ? Math.floor(score) : calculateBandScore(score, total, module.toLowerCase() as Module | "overall", "general")}
|
||||||
|
</Text>
|
||||||
|
<Text style={{fontSize: 8}}>out of {module === "level" ? total : "9.0"}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ const GroupTestReport = ({
|
|||||||
</View>
|
</View>
|
||||||
<View style={[{ paddingBottom: 30 }, styles.separator]}></View>
|
<View style={[{ paddingBottom: 30 }, styles.separator]}></View>
|
||||||
<View style={{ flexGrow: 1 }}></View>
|
<View style={{ flexGrow: 1 }}></View>
|
||||||
<TestReportFooter />
|
<TestReportFooter userId={id} />
|
||||||
</Page>
|
</Page>
|
||||||
<Page style={styles.body}>
|
<Page style={styles.body}>
|
||||||
<View
|
<View
|
||||||
@@ -297,7 +297,7 @@ const GroupTestReport = ({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ flexGrow: 1 }}></View>
|
<View style={{ flexGrow: 1 }}></View>
|
||||||
<TestReportFooter />
|
<TestReportFooter userId={id} />
|
||||||
</Page>
|
</Page>
|
||||||
</Document>
|
</Document>
|
||||||
);
|
);
|
||||||
|
|||||||
24
src/exams/pdf/list.item.tsx
Normal file
24
src/exams/pdf/list.item.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Text, View, StyleSheet } from "@react-pdf/renderer";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
},
|
||||||
|
bullet: {
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ListItem = ({ text, textStyle }: { text: string, textStyle: any[] }) => {
|
||||||
|
return (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={styles.bullet}>
|
||||||
|
<Text style={textStyle}>{"\u2022" + " "}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={textStyle}>{text}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListItem;
|
||||||
@@ -69,7 +69,6 @@ const TestReportFooter = ({ userId }: Props) => (
|
|||||||
<Text style={styles.textUnderline}>info@encoach.com</Text>
|
<Text style={styles.textUnderline}>info@encoach.com</Text>
|
||||||
<Text>https://encoach.com</Text>
|
<Text>https://encoach.com</Text>
|
||||||
<View style={styles.spacedRow}>
|
<View style={styles.spacedRow}>
|
||||||
<Text>Group ID: TRI64BNBOIU5043</Text>
|
|
||||||
<Text
|
<Text
|
||||||
// style={styles.pageNumber}
|
// style={styles.pageNumber}
|
||||||
render={({ pageNumber, totalPages }) =>
|
render={({ pageNumber, totalPages }) =>
|
||||||
|
|||||||
@@ -6,11 +6,17 @@ import { styles } from "./styles";
|
|||||||
|
|
||||||
import { StyleSheet } from "@react-pdf/renderer";
|
import { StyleSheet } from "@react-pdf/renderer";
|
||||||
import TestReportFooter from "./test.report.footer";
|
import TestReportFooter from "./test.report.footer";
|
||||||
|
import ListItem from "./list.item";
|
||||||
|
|
||||||
const customStyles = StyleSheet.create({
|
const customStyles = StyleSheet.create({
|
||||||
testDetails: {
|
testDetails: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: 4,
|
gap: 4,
|
||||||
},
|
},
|
||||||
|
testDetailsContainer: {
|
||||||
|
display: "flex",
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -82,7 +88,6 @@ const TestReport = ({
|
|||||||
</Text>
|
</Text>
|
||||||
<View style={styles.textMargin}>
|
<View style={styles.textMargin}>
|
||||||
<Text style={defaultTextStyle}>Name: {name}</Text>
|
<Text style={defaultTextStyle}>Name: {name}</Text>
|
||||||
<Text style={defaultTextStyle}>ID: {id}</Text>
|
|
||||||
<Text style={defaultTextStyle}>Email: {email}</Text>
|
<Text style={defaultTextStyle}>Email: {email}</Text>
|
||||||
<Text style={defaultTextStyle}>Gender: {gender}</Text>
|
<Text style={defaultTextStyle}>Gender: {gender}</Text>
|
||||||
<Text style={defaultTextStyle}>Passport ID: {passportId}</Text>
|
<Text style={defaultTextStyle}>Passport ID: {passportId}</Text>
|
||||||
@@ -124,7 +129,7 @@ const TestReport = ({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={[{ paddingTop: 30 }, styles.separator]}></View>
|
<View style={[{ paddingTop: 30 }, styles.separator]}></View>
|
||||||
<TestReportFooter userId={id}/>
|
<TestReportFooter userId={id} />
|
||||||
</Page>
|
</Page>
|
||||||
<Page style={styles.body}>
|
<Page style={styles.body}>
|
||||||
<View>
|
<View>
|
||||||
@@ -149,15 +154,46 @@ const TestReport = ({
|
|||||||
.filter(
|
.filter(
|
||||||
({ suggestions, evaluation }) => suggestions || evaluation
|
({ suggestions, evaluation }) => suggestions || evaluation
|
||||||
)
|
)
|
||||||
.map(({ module, suggestions, evaluation }) => (
|
.map(
|
||||||
<View key={module} style={customStyles.testDetails}>
|
({
|
||||||
<Text style={[...defaultSkillsTitleStyle, styles.textBold]}>
|
module,
|
||||||
{module}
|
suggestions,
|
||||||
</Text>
|
evaluation,
|
||||||
<Text style={defaultSkillsTextStyle}>{evaluation}</Text>
|
bullet_points = [],
|
||||||
<Text style={defaultSkillsTextStyle}>{suggestions}</Text>
|
}) => (
|
||||||
</View>
|
<View key={module} style={customStyles.testDetailsContainer}>
|
||||||
))}
|
<View style={customStyles.testDetails}>
|
||||||
|
<Text
|
||||||
|
style={[...defaultSkillsTitleStyle, styles.textBold]}
|
||||||
|
>
|
||||||
|
{module}
|
||||||
|
</Text>
|
||||||
|
<Text style={defaultSkillsTextStyle}>{evaluation}</Text>
|
||||||
|
<Text style={defaultSkillsTextStyle}>{suggestions}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={customStyles.testDetails}>
|
||||||
|
{bullet_points.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
style={defaultSkillsTitleStyle}
|
||||||
|
>
|
||||||
|
How to Improve:
|
||||||
|
</Text>
|
||||||
|
<View>
|
||||||
|
{bullet_points.map((text: string) => (
|
||||||
|
<ListItem
|
||||||
|
key={text}
|
||||||
|
text={text}
|
||||||
|
textStyle={defaultSkillsTextStyle}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.alignRightRow}>
|
<View style={styles.alignRightRow}>
|
||||||
<Image src={qrcode} style={styles.qrcode} />
|
<Image src={qrcode} style={styles.qrcode} />
|
||||||
@@ -165,7 +201,7 @@ const TestReport = ({
|
|||||||
</View>
|
</View>
|
||||||
<View style={[{ paddingBottom: 30 }, styles.separator]}></View>
|
<View style={[{ paddingBottom: 30 }, styles.separator]}></View>
|
||||||
<View style={{ flexGrow: 1 }}></View>
|
<View style={{ flexGrow: 1 }}></View>
|
||||||
<TestReportFooter userId={id}/>
|
<TestReportFooter userId={id} />
|
||||||
</Page>
|
</Page>
|
||||||
</Document>
|
</Document>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import {initializeApp} from "firebase/app";
|
import {initializeApp} from "firebase/app";
|
||||||
import * as admin from "firebase-admin/app";
|
import * as admin from "firebase-admin/app";
|
||||||
import { getStorage } from "firebase/storage";
|
import {getStorage} from "firebase/storage";
|
||||||
|
|
||||||
const serviceAccount = require("@/constants/serviceAccountKey.json");
|
const stagingServiceAccount = require("@/constants/staging.json");
|
||||||
|
const platformServiceAccount = require("@/constants/platform.json");
|
||||||
|
|
||||||
const firebaseConfig = {
|
const firebaseConfig = {
|
||||||
apiKey: process.env.FIREBASE_PUBLIC_API_KEY || "",
|
apiKey: process.env.FIREBASE_PUBLIC_API_KEY || "",
|
||||||
@@ -16,8 +17,8 @@ const firebaseConfig = {
|
|||||||
export const app = initializeApp(firebaseConfig, Math.random().toString());
|
export const app = initializeApp(firebaseConfig, Math.random().toString());
|
||||||
export const adminApp = admin.initializeApp(
|
export const adminApp = admin.initializeApp(
|
||||||
{
|
{
|
||||||
credential: admin.cert(serviceAccount),
|
credential: admin.cert(process.env.ENVIRONMENT === "platform" ? platformServiceAccount : stagingServiceAccount),
|
||||||
},
|
},
|
||||||
Math.random().toString(),
|
Math.random().toString(),
|
||||||
);
|
);
|
||||||
export const storage = getStorage(app);
|
export const storage = getStorage(app);
|
||||||
|
|||||||
@@ -1,45 +1,42 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { toast } from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import { BsArchive } from "react-icons/bs";
|
import {BsArchive} from "react-icons/bs";
|
||||||
|
|
||||||
export const useAssignmentArchive = (
|
export const useAssignmentArchive = (assignmentId: string, reload?: Function) => {
|
||||||
assignmentId: string,
|
const [loading, setLoading] = React.useState(false);
|
||||||
reload?: Function
|
const archive = () => {
|
||||||
) => {
|
// archive assignment
|
||||||
const [loading, setLoading] = React.useState(false);
|
setLoading(true);
|
||||||
const archive = () => {
|
axios
|
||||||
// archive assignment
|
.post(`/api/assignments/${assignmentId}/archive`)
|
||||||
setLoading(true);
|
.then((res) => {
|
||||||
axios
|
toast.success("Assignment archived!");
|
||||||
.post(`/api/assignments/${assignmentId}/archive`)
|
if (reload) reload();
|
||||||
.then((res) => {
|
setLoading(false);
|
||||||
toast.success("Assignment archived!");
|
})
|
||||||
if(reload) reload();
|
.catch((err) => {
|
||||||
setLoading(false);
|
toast.error("Failed to archive the assignment!");
|
||||||
})
|
setLoading(false);
|
||||||
.catch((err) => {
|
});
|
||||||
toast.error("Failed to archive the assignment!");
|
};
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderIcon = (downloadClasses: string, loadingClasses: string) => {
|
const renderIcon = (downloadClasses: string, loadingClasses: string) => {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <span className={`${loadingClasses} loading loading-infinity w-6`} />;
|
||||||
<span className={`${loadingClasses} loading loading-infinity w-6`} />
|
}
|
||||||
);
|
return (
|
||||||
}
|
<div
|
||||||
return (
|
className="tooltip flex items-center justify-center w-fit h-fit"
|
||||||
<BsArchive
|
data-tip="Archive assignment"
|
||||||
className={`${downloadClasses} text-2xl cursor-pointer`}
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
archive();
|
||||||
archive();
|
}}>
|
||||||
}}
|
<BsArchive className={`${downloadClasses} text-2xl cursor-pointer tooltip`} />
|
||||||
/>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return renderIcon;
|
return renderIcon;
|
||||||
};
|
};
|
||||||
|
|||||||
42
src/hooks/useAssignmentUnarchive.tsx
Normal file
42
src/hooks/useAssignmentUnarchive.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import React from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import {toast} from "react-toastify";
|
||||||
|
import {BsArchive, BsFileEarmarkCheck, BsFileEarmarkCheckFill} from "react-icons/bs";
|
||||||
|
|
||||||
|
export const useAssignmentUnarchive = (assignmentId: string, reload?: Function) => {
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const archive = () => {
|
||||||
|
// archive assignment
|
||||||
|
setLoading(true);
|
||||||
|
axios
|
||||||
|
.post(`/api/assignments/${assignmentId}/unarchive`)
|
||||||
|
.then((res) => {
|
||||||
|
toast.success("Assignment unarchived!");
|
||||||
|
if (reload) reload();
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error("Failed to unarchive 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="Unarchive assignment"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
archive();
|
||||||
|
}}>
|
||||||
|
<BsFileEarmarkCheck className={`${downloadClasses} text-2xl cursor-pointer tooltip`} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return renderIcon;
|
||||||
|
};
|
||||||
22
src/hooks/useDiscounts.tsx
Normal file
22
src/hooks/useDiscounts.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Discount } from "@/interfaces/paypal";
|
||||||
|
import { Code, Group, User } from "@/interfaces/user";
|
||||||
|
import axios from "axios";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function useDiscounts(creator?: string) {
|
||||||
|
const [discounts, setDiscounts] = useState<Discount[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
|
const getData = () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
axios
|
||||||
|
.get<Discount[]>("/api/discounts")
|
||||||
|
.then((response) => setDiscounts(response.data))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(getData, [creator]);
|
||||||
|
|
||||||
|
return { discounts, isLoading, isError, reload: getData };
|
||||||
|
}
|
||||||
@@ -2,16 +2,23 @@ import {Group, User} from "@/interfaces/user";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
|
||||||
export default function useGroups(admin?: string) {
|
export default function useGroups(admin?: string, userType?: string) {
|
||||||
const [groups, setGroups] = useState<Group[]>([]);
|
const [groups, setGroups] = useState<Group[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isError, setIsError] = useState(false);
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
|
const isMasterType = userType?.startsWith('master');
|
||||||
|
|
||||||
const getData = () => {
|
const getData = () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const url = admin ? `/api/groups?admin=${admin}` : "/api/groups";
|
||||||
axios
|
axios
|
||||||
.get<Group[]>("/api/groups")
|
.get<Group[]>(url)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
if(isMasterType) {
|
||||||
|
return setGroups(response.data);
|
||||||
|
}
|
||||||
const filter = (g: Group) => g.admin === admin || g.participants.includes(admin || "");
|
const filter = (g: Group) => g.admin === admin || g.participants.includes(admin || "");
|
||||||
|
|
||||||
const filteredGroups = admin ? response.data.filter(filter) : response.data;
|
const filteredGroups = admin ? response.data.filter(filter) : response.data;
|
||||||
@@ -20,7 +27,7 @@ export default function useGroups(admin?: string) {
|
|||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(getData, [admin]);
|
useEffect(getData, [admin, isMasterType]);
|
||||||
|
|
||||||
return {groups, isLoading, isError, reload: getData};
|
return {groups, isLoading, isError, reload: getData};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {useState, useMemo} from 'react';
|
import {useState, useMemo} from "react";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
|
|
||||||
/*fields example = [
|
/*fields example = [
|
||||||
@@ -6,43 +6,33 @@ import Input from "@/components/Low/Input";
|
|||||||
['companyInformation', 'companyInformation', 'name']
|
['companyInformation', 'companyInformation', 'name']
|
||||||
]*/
|
]*/
|
||||||
|
|
||||||
|
|
||||||
const getFieldValue = (fields: string[], data: any): string => {
|
const getFieldValue = (fields: string[], data: any): string => {
|
||||||
if(fields.length === 0) return data;
|
if (fields.length === 0) return data;
|
||||||
const [key, ...otherFields] = fields;
|
const [key, ...otherFields] = fields;
|
||||||
|
|
||||||
if(data[key]) return getFieldValue(otherFields, data[key]);
|
if (data[key]) return getFieldValue(otherFields, data[key]);
|
||||||
return data;
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useListSearch<T>(fields: string[][], rows: T[]) {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [fields, rows, text]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: updatedRows,
|
||||||
|
renderSearch,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useListSearch = (fields: string[][], rows: any[]) => {
|
|
||||||
const [text, setText] = useState('');
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}, [fields, rows, text])
|
|
||||||
|
|
||||||
return {
|
|
||||||
rows: updatedRows,
|
|
||||||
renderSearch,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,18 +2,22 @@ import {Stat, User} from "@/interfaces/user";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
|
||||||
export default function useStats(id?: string) {
|
export default function useStats(id?: string, shouldNotQuery?: boolean) {
|
||||||
const [stats, setStats] = useState<Stat[]>([]);
|
const [stats, setStats] = useState<Stat[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isError, setIsError] = useState(false);
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const getData = () => {
|
||||||
|
if (shouldNotQuery) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.get<Stat[]>(!id ? "/api/stats" : `/api/stats/user/${id}`)
|
.get<Stat[]>(!id ? "/api/stats" : `/api/stats/user/${id}`)
|
||||||
.then((response) => setStats(response.data))
|
.then((response) => setStats(response.data.filter((x) => (id ? x.user === id : true))))
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
}, [id]);
|
};
|
||||||
|
|
||||||
return {stats, isLoading, isError};
|
useEffect(getData, [id, shouldNotQuery]);
|
||||||
|
|
||||||
|
return {stats, reload: getData, isLoading, isError};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,13 +27,18 @@ export interface ReadingPart {
|
|||||||
export interface LevelExam {
|
export interface LevelExam {
|
||||||
module: "level";
|
module: "level";
|
||||||
id: string;
|
id: string;
|
||||||
exercises: Exercise[];
|
parts: LevelPart[];
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
difficulty?: Difficulty;
|
difficulty?: Difficulty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LevelPart {
|
||||||
|
context?: string;
|
||||||
|
exercises: Exercise[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ListeningExam {
|
export interface ListeningExam {
|
||||||
parts: ListeningPart[];
|
parts: ListeningPart[];
|
||||||
id: string;
|
id: string;
|
||||||
@@ -64,6 +69,7 @@ export interface UserSolution {
|
|||||||
missing: number;
|
missing: number;
|
||||||
};
|
};
|
||||||
exercise: string;
|
exercise: string;
|
||||||
|
isDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WritingExam {
|
export interface WritingExam {
|
||||||
@@ -105,18 +111,18 @@ export type Exercise =
|
|||||||
export interface Evaluation {
|
export interface Evaluation {
|
||||||
comment: string;
|
comment: string;
|
||||||
overall: number;
|
overall: number;
|
||||||
task_response: {[key: string]: number};
|
task_response: {[key: string]: number | {grade: number; comment: string}};
|
||||||
misspelled_pairs?: {correction: string | null; misspelled: string}[];
|
misspelled_pairs?: {correction: string | null; misspelled: string}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InteractiveSpeakingEvaluation extends Evaluation {
|
interface InteractiveSpeakingEvaluation extends Evaluation {
|
||||||
perfect_answer_1?: string;
|
perfect_answer_1?: {answer: string};
|
||||||
transcript_1?: string;
|
transcript_1?: string;
|
||||||
fixed_text_1?: string;
|
fixed_text_1?: string;
|
||||||
perfect_answer_2?: string;
|
perfect_answer_2?: {answer: string};
|
||||||
transcript_2?: string;
|
transcript_2?: string;
|
||||||
fixed_text_2?: string;
|
fixed_text_2?: string;
|
||||||
perfect_answer_3?: string;
|
perfect_answer_3?: {answer: string};
|
||||||
transcript_3?: string;
|
transcript_3?: string;
|
||||||
fixed_text_3?: string;
|
fixed_text_3?: string;
|
||||||
}
|
}
|
||||||
@@ -146,17 +152,36 @@ export interface WritingExercise {
|
|||||||
userSolutions: {
|
userSolutions: {
|
||||||
id: string;
|
id: string;
|
||||||
solution: string;
|
solution: string;
|
||||||
evaluation?: CommonEvaluation;
|
evaluation?: WritingEvaluation;
|
||||||
}[];
|
}[];
|
||||||
topic?: string;
|
topic?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AIDetectionAttributes {
|
||||||
|
predicted_class: "ai" | "mixed" | "human";
|
||||||
|
confidence_category: "high" | "medium" | "low";
|
||||||
|
class_probabilities: {
|
||||||
|
ai: number;
|
||||||
|
human: number;
|
||||||
|
mixed: number;
|
||||||
|
};
|
||||||
|
sentences: {
|
||||||
|
sentence: string;
|
||||||
|
highlight_sentence_for_ai: boolean;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WritingEvaluation extends CommonEvaluation {
|
||||||
|
ai_detection?: AIDetectionAttributes;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SpeakingExercise {
|
export interface SpeakingExercise {
|
||||||
id: string;
|
id: string;
|
||||||
type: "speaking";
|
type: "speaking";
|
||||||
title: string;
|
title: string;
|
||||||
text: string;
|
text: string;
|
||||||
prompts: string[];
|
prompts: string[];
|
||||||
|
suffix?: string;
|
||||||
video_url: string;
|
video_url: string;
|
||||||
userSolutions: {
|
userSolutions: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -170,6 +195,8 @@ export interface InteractiveSpeakingExercise {
|
|||||||
id: string;
|
id: string;
|
||||||
type: "interactiveSpeaking";
|
type: "interactiveSpeaking";
|
||||||
title: string;
|
title: string;
|
||||||
|
first_title?: string;
|
||||||
|
second_title?: string;
|
||||||
text: string;
|
text: string;
|
||||||
prompts: {text: string; video_url: string}[];
|
prompts: {text: string; video_url: string}[];
|
||||||
userSolutions: {
|
userSolutions: {
|
||||||
@@ -178,13 +205,16 @@ export interface InteractiveSpeakingExercise {
|
|||||||
evaluation?: InteractiveSpeakingEvaluation;
|
evaluation?: InteractiveSpeakingEvaluation;
|
||||||
}[];
|
}[];
|
||||||
topic?: string;
|
topic?: string;
|
||||||
|
first_topic?: string;
|
||||||
|
second_topic?: string;
|
||||||
|
variant?: "initial" | "final";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FillBlanksExercise {
|
export interface FillBlanksExercise {
|
||||||
prompt: string; // *EXAMPLE: "Complete the summary below. Click a blank to select the corresponding word for it."
|
prompt: string; // *EXAMPLE: "Complete the summary below. Click a blank to select the corresponding word for it."
|
||||||
type: "fillBlanks";
|
type: "fillBlanks";
|
||||||
id: string;
|
id: string;
|
||||||
words: string[]; // *EXAMPLE: ["preserve", "unaware"]
|
words: (string | {letter: string; word: string})[]; // *EXAMPLE: ["preserve", "unaware"]
|
||||||
text: string; // *EXAMPLE: "They tried to {{1}} burning"
|
text: string; // *EXAMPLE: "They tried to {{1}} burning"
|
||||||
allowRepetition: boolean;
|
allowRepetition: boolean;
|
||||||
solutions: {
|
solutions: {
|
||||||
@@ -232,17 +262,21 @@ export interface MatchSentencesExercise {
|
|||||||
id: string;
|
id: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
userSolutions: {question: string; option: string}[];
|
userSolutions: {question: string; option: string}[];
|
||||||
sentences: {
|
sentences: MatchSentenceExerciseSentence[];
|
||||||
id: string;
|
|
||||||
sentence: string;
|
|
||||||
solution: string;
|
|
||||||
color: string;
|
|
||||||
}[];
|
|
||||||
allowRepetition: boolean;
|
allowRepetition: boolean;
|
||||||
options: {
|
options: MatchSentenceExerciseOption[];
|
||||||
id: string;
|
}
|
||||||
sentence: string;
|
|
||||||
}[];
|
export interface MatchSentenceExerciseSentence {
|
||||||
|
id: string;
|
||||||
|
sentence: string;
|
||||||
|
solution: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchSentenceExerciseOption {
|
||||||
|
id: string;
|
||||||
|
sentence: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MultipleChoiceExercise {
|
export interface MultipleChoiceExercise {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface ModuleScore {
|
|||||||
png?: string;
|
png?: string;
|
||||||
evaluation?: string;
|
evaluation?: string;
|
||||||
suggestions?: string;
|
suggestions?: string;
|
||||||
|
bullet_points?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StudentData {
|
export interface StudentData {
|
||||||
|
|||||||
118
src/interfaces/paymob.ts
Normal file
118
src/interfaces/paymob.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
export interface PaymentIntention {
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
payment_methods: number[];
|
||||||
|
items: any[];
|
||||||
|
billing_data: BillingData;
|
||||||
|
customer: Customer;
|
||||||
|
extras: IntentionExtras;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BillingData {
|
||||||
|
apartment: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
street: string;
|
||||||
|
building: string;
|
||||||
|
phone_number: string;
|
||||||
|
country: string;
|
||||||
|
email: string;
|
||||||
|
floor: string;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Customer {
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
email: string;
|
||||||
|
extras: IntentionExtras;
|
||||||
|
}
|
||||||
|
|
||||||
|
type IntentionExtras = {[key: string]: string | number};
|
||||||
|
|
||||||
|
export interface IntentionResult {
|
||||||
|
payment_keys: PaymentKeysItem[];
|
||||||
|
id: string;
|
||||||
|
intention_detail: IntentionDetail;
|
||||||
|
client_secret: string;
|
||||||
|
payment_methods: PaymentMethodsItem[];
|
||||||
|
special_reference: null;
|
||||||
|
extras: Extras;
|
||||||
|
confirmed: boolean;
|
||||||
|
status: string;
|
||||||
|
created: string;
|
||||||
|
card_detail: null;
|
||||||
|
object: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaymentKeysItem {
|
||||||
|
integration: number;
|
||||||
|
key: string;
|
||||||
|
gateway_type: string;
|
||||||
|
iframe_id: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IntentionDetail {
|
||||||
|
amount: number;
|
||||||
|
items: ItemsItem[];
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemsItem {
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaymentMethodsItem {
|
||||||
|
integration_id: number;
|
||||||
|
alias: null;
|
||||||
|
name: null;
|
||||||
|
method_type: string;
|
||||||
|
currency: string;
|
||||||
|
live: boolean;
|
||||||
|
use_cvc_with_moto: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Extras {
|
||||||
|
creation_extras: IntentionExtras;
|
||||||
|
confirmation_extras: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransactionResult {
|
||||||
|
paymob_request_id: null;
|
||||||
|
intention: IntentionResult;
|
||||||
|
hmac: string;
|
||||||
|
transaction: Transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Transaction {
|
||||||
|
amount_cents: number;
|
||||||
|
created_at: string;
|
||||||
|
currency: string;
|
||||||
|
error_occured: boolean;
|
||||||
|
has_parent_transaction: boolean;
|
||||||
|
id: number;
|
||||||
|
integration_id: number;
|
||||||
|
is_3d_secure: boolean;
|
||||||
|
is_auth: boolean;
|
||||||
|
is_capture: boolean;
|
||||||
|
is_refunded: boolean;
|
||||||
|
is_standalone_payment: boolean;
|
||||||
|
is_voided: boolean;
|
||||||
|
order: Order;
|
||||||
|
owner: number;
|
||||||
|
pending: boolean;
|
||||||
|
source_data: Source_data;
|
||||||
|
success: boolean;
|
||||||
|
receipt: string;
|
||||||
|
}
|
||||||
|
interface Order {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
interface Source_data {
|
||||||
|
pan: string;
|
||||||
|
sub_type: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
@@ -20,6 +20,13 @@ export interface Package {
|
|||||||
price: number;
|
price: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Discount {
|
||||||
|
id: string;
|
||||||
|
percentage: number;
|
||||||
|
domain: string;
|
||||||
|
validUntil?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
export type DurationUnit = "weeks" | "days" | "months" | "years";
|
export type DurationUnit = "weeks" | "days" | "months" | "years";
|
||||||
|
|
||||||
export interface Payment {
|
export interface Payment {
|
||||||
@@ -36,7 +43,6 @@ export interface Payment {
|
|||||||
commissionTransfer?: string;
|
commissionTransfer?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface PaypalPayment {
|
export interface PaypalPayment {
|
||||||
orderId: string;
|
orderId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -47,4 +53,4 @@ export interface PaypalPayment {
|
|||||||
subscriptionDuration: number;
|
subscriptionDuration: number;
|
||||||
subscriptionDurationUnit: DurationUnit;
|
subscriptionDurationUnit: DurationUnit;
|
||||||
subscriptionExpirationDate: Date;
|
subscriptionExpirationDate: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/interfaces/permissions.ts
Normal file
49
src/interfaces/permissions.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
export const markets = ["au", "br", "de"] as const;
|
||||||
|
|
||||||
|
export const permissions = [
|
||||||
|
// generate codes are basicly invites
|
||||||
|
"createCodeStudent",
|
||||||
|
"createCodeTeacher",
|
||||||
|
"createCodeCorporate",
|
||||||
|
"createCodeCountryManager",
|
||||||
|
"createCodeAdmin",
|
||||||
|
// exams
|
||||||
|
"createReadingExam",
|
||||||
|
"createListeningExam",
|
||||||
|
"createWritingExam",
|
||||||
|
"createSpeakingExam",
|
||||||
|
"createLevelExam",
|
||||||
|
// view pages
|
||||||
|
"viewExams",
|
||||||
|
"viewExercises",
|
||||||
|
"viewRecords",
|
||||||
|
"viewStats",
|
||||||
|
"viewTickets",
|
||||||
|
"viewPaymentRecords",
|
||||||
|
// view data
|
||||||
|
"viewStudent",
|
||||||
|
"viewTeacher",
|
||||||
|
"viewCorporate",
|
||||||
|
"viewCountryManager",
|
||||||
|
"viewAdmin",
|
||||||
|
// edit data
|
||||||
|
"editStudent",
|
||||||
|
"editTeacher",
|
||||||
|
"editCorporate",
|
||||||
|
"editCountryManager",
|
||||||
|
"editAdmin",
|
||||||
|
// delete data
|
||||||
|
"deleteStudent",
|
||||||
|
"deleteTeacher",
|
||||||
|
"deleteCorporate",
|
||||||
|
"deleteCountryManager",
|
||||||
|
"deleteAdmin",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PermissionType = (typeof permissions)[keyof typeof permissions];
|
||||||
|
|
||||||
|
export interface Permission {
|
||||||
|
id: string;
|
||||||
|
type: PermissionType;
|
||||||
|
users: string[];
|
||||||
|
}
|
||||||
@@ -1,38 +1,48 @@
|
|||||||
import { Type } from "./user";
|
import {Module} from ".";
|
||||||
|
import {Type} from "./user";
|
||||||
|
|
||||||
export interface Ticket {
|
export interface Ticket {
|
||||||
id: string;
|
id: string;
|
||||||
date: string;
|
date: string;
|
||||||
status: TicketStatus;
|
status: TicketStatus;
|
||||||
type: TicketType;
|
type: TicketType;
|
||||||
reporter: TicketReporter;
|
reporter: TicketReporter;
|
||||||
reportedFrom: string;
|
reportedFrom: string;
|
||||||
description: string;
|
description: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
assignedTo?: string;
|
assignedTo?: string;
|
||||||
|
examInformation?: {
|
||||||
|
exams: string[];
|
||||||
|
exam: string;
|
||||||
|
selectedModules: Module[];
|
||||||
|
moduleIndex: number;
|
||||||
|
partIndex: number;
|
||||||
|
exerciseIndex: number;
|
||||||
|
questionIndex: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketReporter {
|
export interface TicketReporter {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
type: Type;
|
type: Type;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TicketType = "feedback" | "bug" | "help";
|
export type TicketType = "feedback" | "bug" | "help";
|
||||||
export const TicketTypeLabel: { [key in TicketType]: string } = {
|
export const TicketTypeLabel: {[key in TicketType]: string} = {
|
||||||
feedback: "Feedback",
|
feedback: "Feedback",
|
||||||
bug: "Bug",
|
bug: "Bug",
|
||||||
help: "Help",
|
help: "Help",
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TicketStatus = "submitted" | "in-progress" | "completed";
|
export type TicketStatus = "submitted" | "in-progress" | "completed";
|
||||||
export const TicketStatusLabel: { [key in TicketStatus]: string } = {
|
export const TicketStatusLabel: {[key in TicketStatus]: string} = {
|
||||||
submitted: "Submitted",
|
submitted: "Submitted",
|
||||||
"in-progress": "In Progress",
|
"in-progress": "In Progress",
|
||||||
completed: "Completed",
|
completed: "Completed",
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface TicketWithCorporate extends Ticket {
|
export interface TicketWithCorporate extends Ticket {
|
||||||
corporate?: string;
|
corporate?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,147 +1,189 @@
|
|||||||
import {Module} from ".";
|
import { Module } from ".";
|
||||||
import {InstructorGender} from "./exam";
|
import { InstructorGender } from "./exam";
|
||||||
|
import { PermissionType } from "./permissions";
|
||||||
|
|
||||||
export type User = StudentUser | TeacherUser | CorporateUser | AgentUser | AdminUser | DeveloperUser;
|
export type User =
|
||||||
|
| StudentUser
|
||||||
|
| TeacherUser
|
||||||
|
| CorporateUser
|
||||||
|
| AgentUser
|
||||||
|
| AdminUser
|
||||||
|
| DeveloperUser
|
||||||
|
| MasterCorporateUser;
|
||||||
|
export type UserStatus = "active" | "disabled" | "paymentDue";
|
||||||
|
|
||||||
export interface BasicUser {
|
export interface BasicUser {
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
profilePicture: string;
|
profilePicture: string;
|
||||||
id: string;
|
id: string;
|
||||||
isFirstLogin: boolean;
|
isFirstLogin: boolean;
|
||||||
focus: "academic" | "general";
|
focus: "academic" | "general";
|
||||||
levels: {[key in Module]: number};
|
levels: { [key in Module]: number };
|
||||||
desiredLevels: {[key in Module]: number};
|
desiredLevels: { [key in Module]: number };
|
||||||
type: Type;
|
type: Type;
|
||||||
bio: string;
|
bio: string;
|
||||||
isVerified: boolean;
|
isVerified: boolean;
|
||||||
subscriptionExpirationDate?: null | Date;
|
subscriptionExpirationDate?: null | Date;
|
||||||
registrationDate?: Date;
|
registrationDate?: Date;
|
||||||
status: "active" | "disabled" | "paymentDue";
|
status: UserStatus;
|
||||||
|
permissions: PermissionType[],
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StudentUser extends BasicUser {
|
export interface StudentUser extends BasicUser {
|
||||||
type: "student";
|
type: "student";
|
||||||
preferredGender?: InstructorGender;
|
preferredGender?: InstructorGender;
|
||||||
demographicInformation?: DemographicInformation;
|
demographicInformation?: DemographicInformation;
|
||||||
preferredTopics?: string[];
|
preferredTopics?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeacherUser extends BasicUser {
|
export interface TeacherUser extends BasicUser {
|
||||||
type: "teacher";
|
type: "teacher";
|
||||||
demographicInformation?: DemographicInformation;
|
demographicInformation?: DemographicInformation;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CorporateUser extends BasicUser {
|
export interface CorporateUser extends BasicUser {
|
||||||
type: "corporate";
|
type: "corporate";
|
||||||
corporateInformation: CorporateInformation;
|
corporateInformation: CorporateInformation;
|
||||||
demographicInformation?: DemographicCorporateInformation;
|
demographicInformation?: DemographicCorporateInformation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MasterCorporateUser extends BasicUser {
|
||||||
|
type: "mastercorporate";
|
||||||
|
corporateInformation: CorporateInformation;
|
||||||
|
demographicInformation?: DemographicCorporateInformation;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentUser extends BasicUser {
|
export interface AgentUser extends BasicUser {
|
||||||
type: "agent";
|
type: "agent";
|
||||||
agentInformation: AgentInformation;
|
agentInformation: AgentInformation;
|
||||||
demographicInformation?: DemographicInformation;
|
demographicInformation?: DemographicInformation;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUser extends BasicUser {
|
export interface AdminUser extends BasicUser {
|
||||||
type: "admin";
|
type: "admin";
|
||||||
demographicInformation?: DemographicInformation;
|
demographicInformation?: DemographicInformation;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeveloperUser extends BasicUser {
|
export interface DeveloperUser extends BasicUser {
|
||||||
type: "developer";
|
type: "developer";
|
||||||
preferredGender?: InstructorGender;
|
preferredGender?: InstructorGender;
|
||||||
demographicInformation?: DemographicInformation;
|
demographicInformation?: DemographicInformation;
|
||||||
preferredTopics?: string[];
|
preferredTopics?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CorporateInformation {
|
export interface CorporateInformation {
|
||||||
companyInformation: CompanyInformation;
|
companyInformation: CompanyInformation;
|
||||||
monthlyDuration: number;
|
monthlyDuration: number;
|
||||||
payment?: {
|
payment?: {
|
||||||
value: number;
|
value: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
commission: number;
|
commission: number;
|
||||||
};
|
};
|
||||||
referralAgent?: string;
|
referralAgent?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentInformation {
|
export interface AgentInformation {
|
||||||
companyName: string;
|
companyName: string;
|
||||||
commercialRegistration: string;
|
commercialRegistration: string;
|
||||||
|
companyArabName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompanyInformation {
|
export interface CompanyInformation {
|
||||||
name: string;
|
name: string;
|
||||||
userAmount: number;
|
userAmount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DemographicInformation {
|
export interface DemographicInformation {
|
||||||
country: string;
|
country: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
gender: Gender;
|
gender: Gender;
|
||||||
employment: EmploymentStatus;
|
employment: EmploymentStatus;
|
||||||
passport_id?: string;
|
passport_id?: string;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DemographicCorporateInformation {
|
export interface DemographicCorporateInformation {
|
||||||
country: string;
|
country: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
gender: Gender;
|
gender: Gender;
|
||||||
position: string;
|
position: string;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Gender = "male" | "female" | "other";
|
export type Gender = "male" | "female" | "other";
|
||||||
export type EmploymentStatus = "employed" | "student" | "self-employed" | "unemployed" | "retired" | "other";
|
export type EmploymentStatus =
|
||||||
export const EMPLOYMENT_STATUS: {status: EmploymentStatus; label: string}[] = [
|
| "employed"
|
||||||
{status: "student", label: "Student"},
|
| "student"
|
||||||
{status: "employed", label: "Employed"},
|
| "self-employed"
|
||||||
{status: "unemployed", label: "Unemployed"},
|
| "unemployed"
|
||||||
{status: "self-employed", label: "Self-employed"},
|
| "retired"
|
||||||
{status: "retired", label: "Retired"},
|
| "other";
|
||||||
{status: "other", label: "Other"},
|
export const EMPLOYMENT_STATUS: { status: EmploymentStatus; label: string }[] =
|
||||||
];
|
[
|
||||||
|
{ status: "student", label: "Student" },
|
||||||
|
{ status: "employed", label: "Employed" },
|
||||||
|
{ status: "unemployed", label: "Unemployed" },
|
||||||
|
{ status: "self-employed", label: "Self-employed" },
|
||||||
|
{ status: "retired", label: "Retired" },
|
||||||
|
{ status: "other", label: "Other" },
|
||||||
|
];
|
||||||
|
|
||||||
export interface Stat {
|
export interface Stat {
|
||||||
id: string;
|
id: string;
|
||||||
user: string;
|
user: string;
|
||||||
exam: string;
|
exam: string;
|
||||||
exercise: string;
|
exercise: string;
|
||||||
session: string;
|
session: string;
|
||||||
date: number;
|
date: number;
|
||||||
module: Module;
|
module: Module;
|
||||||
solutions: any[];
|
solutions: any[];
|
||||||
type: string;
|
type: string;
|
||||||
timeSpent?: number;
|
timeSpent?: number;
|
||||||
assignment?: string;
|
inactivity?: number;
|
||||||
score: {
|
assignment?: string;
|
||||||
correct: number;
|
score: {
|
||||||
total: number;
|
correct: number;
|
||||||
missing: number;
|
total: number;
|
||||||
};
|
missing: number;
|
||||||
|
};
|
||||||
|
isDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Group {
|
export interface Group {
|
||||||
admin: string;
|
admin: string;
|
||||||
name: string;
|
name: string;
|
||||||
participants: string[];
|
participants: string[];
|
||||||
id: string;
|
id: string;
|
||||||
disableEditing?: boolean;
|
disableEditing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Code {
|
export interface Code {
|
||||||
code: string;
|
code: string;
|
||||||
creator: string;
|
creator: string;
|
||||||
expiryDate: Date;
|
expiryDate: Date;
|
||||||
type: Type;
|
type: Type;
|
||||||
userId?: string;
|
creationDate?: string;
|
||||||
email?: string;
|
userId?: string;
|
||||||
name?: string;
|
email?: string;
|
||||||
passport_id?: string;
|
name?: string;
|
||||||
|
passport_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent";
|
export type Type =
|
||||||
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent"];
|
| "student"
|
||||||
|
| "teacher"
|
||||||
|
| "corporate"
|
||||||
|
| "admin"
|
||||||
|
| "developer"
|
||||||
|
| "agent"
|
||||||
|
| "mastercorporate";
|
||||||
|
export const userTypes: Type[] = [
|
||||||
|
"student",
|
||||||
|
"teacher",
|
||||||
|
"corporate",
|
||||||
|
"admin",
|
||||||
|
"developer",
|
||||||
|
"agent",
|
||||||
|
"mastercorporate",
|
||||||
|
];
|
||||||
|
|||||||
@@ -16,18 +16,58 @@ import { useFilePicker } from "use-file-picker";
|
|||||||
import readXlsxFile from "read-excel-file";
|
import readXlsxFile from "read-excel-file";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import { BsFileEarmarkEaselFill, BsQuestionCircleFill } from "react-icons/bs";
|
import { BsFileEarmarkEaselFill, BsQuestionCircleFill } from "react-icons/bs";
|
||||||
|
import { checkAccess } from "@/utils/permissions";
|
||||||
|
import { PermissionType } from "@/interfaces/permissions";
|
||||||
const EMAIL_REGEX = new RegExp(
|
const EMAIL_REGEX = new RegExp(
|
||||||
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/,
|
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/
|
||||||
);
|
);
|
||||||
|
|
||||||
const USER_TYPE_PERMISSIONS: { [key in Type]: Type[] } = {
|
const USER_TYPE_PERMISSIONS: {
|
||||||
student: [],
|
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
||||||
teacher: [],
|
} = {
|
||||||
agent: [],
|
student: {
|
||||||
corporate: ["student", "teacher"],
|
perm: "createCodeStudent",
|
||||||
admin: ["student", "teacher", "agent", "corporate", "admin"],
|
list: [],
|
||||||
developer: ["student", "teacher", "agent", "corporate", "admin", "developer"],
|
},
|
||||||
|
teacher: {
|
||||||
|
perm: "createCodeTeacher",
|
||||||
|
list: [],
|
||||||
|
},
|
||||||
|
agent: {
|
||||||
|
perm: "createCodeCountryManager",
|
||||||
|
list: [],
|
||||||
|
},
|
||||||
|
corporate: {
|
||||||
|
perm: "createCodeCorporate",
|
||||||
|
list: ["student", "teacher"],
|
||||||
|
},
|
||||||
|
mastercorporate: {
|
||||||
|
perm: undefined,
|
||||||
|
list: ["student", "teacher", "corporate"],
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
perm: "createCodeAdmin",
|
||||||
|
list: [
|
||||||
|
"student",
|
||||||
|
"teacher",
|
||||||
|
"agent",
|
||||||
|
"corporate",
|
||||||
|
"admin",
|
||||||
|
"mastercorporate",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
developer: {
|
||||||
|
perm: undefined,
|
||||||
|
list: [
|
||||||
|
"student",
|
||||||
|
"teacher",
|
||||||
|
"agent",
|
||||||
|
"corporate",
|
||||||
|
"admin",
|
||||||
|
"developer",
|
||||||
|
"mastercorporate",
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function BatchCodeGenerator({ user }: { user: User }) {
|
export default function BatchCodeGenerator({ user }: { user: User }) {
|
||||||
@@ -35,7 +75,11 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
|
|||||||
{ email: string; name: string; passport_id: string }[]
|
{ email: string; name: string; passport_id: string }[]
|
||||||
>([]);
|
>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||||
|
user?.subscriptionExpirationDate
|
||||||
|
? moment(user.subscriptionExpirationDate).toDate()
|
||||||
|
: null
|
||||||
|
);
|
||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
const [type, setType] = useState<Type>("student");
|
const [type, setType] = useState<Type>("student");
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
@@ -48,13 +92,6 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
|
|||||||
readAs: "ArrayBuffer",
|
readAs: "ArrayBuffer",
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user && (user.type === "corporate" || user.type === "teacher")) {
|
|
||||||
setExpiryDate(user.subscriptionExpirationDate || null);
|
|
||||||
setIsExpiryDateEnabled(!!user.subscriptionExpirationDate);
|
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||||
}, [isExpiryDateEnabled]);
|
}, [isExpiryDateEnabled]);
|
||||||
@@ -84,12 +121,12 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
|
|||||||
: undefined;
|
: undefined;
|
||||||
})
|
})
|
||||||
.filter((x) => !!x) as typeof infos,
|
.filter((x) => !!x) as typeof infos,
|
||||||
(x) => x.email,
|
(x) => x.email
|
||||||
);
|
);
|
||||||
|
|
||||||
if (information.length === 0) {
|
if (information.length === 0) {
|
||||||
toast.error(
|
toast.error(
|
||||||
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
|
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!"
|
||||||
);
|
);
|
||||||
return clear();
|
return clear();
|
||||||
}
|
}
|
||||||
@@ -97,7 +134,7 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
|
|||||||
setInfos(information);
|
setInfos(information);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(
|
toast.error(
|
||||||
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
|
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!"
|
||||||
);
|
);
|
||||||
return clear();
|
return clear();
|
||||||
}
|
}
|
||||||
@@ -108,7 +145,7 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
|
|||||||
|
|
||||||
const generateAndInvite = async () => {
|
const generateAndInvite = async () => {
|
||||||
const newUsers = infos.filter(
|
const newUsers = infos.filter(
|
||||||
(x) => !users.map((u) => u.email).includes(x.email),
|
(x) => !users.map((u) => u.email).includes(x.email)
|
||||||
);
|
);
|
||||||
const existingUsers = infos
|
const existingUsers = infos
|
||||||
.filter((x) => users.map((u) => u.email).includes(x.email))
|
.filter((x) => users.map((u) => u.email).includes(x.email))
|
||||||
@@ -123,7 +160,9 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
|
|||||||
: undefined;
|
: undefined;
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`,
|
`You are about to ${[newUsersSentence, existingUsersSentence]
|
||||||
|
.filter((x) => !!x)
|
||||||
|
.join(" and ")}, are you sure you want to continue?`
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
@@ -132,13 +171,13 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
|
|||||||
Promise.all(
|
Promise.all(
|
||||||
existingUsers.map(
|
existingUsers.map(
|
||||||
async (u) =>
|
async (u) =>
|
||||||
await axios.post(`/api/invites`, { to: u.id, from: user.id }),
|
await axios.post(`/api/invites`, { to: u.id, from: user.id })
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
.then(() =>
|
.then(() =>
|
||||||
toast.success(
|
toast.success(
|
||||||
`Successfully invited ${existingUsers.length} registered student(s)!`,
|
`Successfully invited ${existingUsers.length} registered student(s)!`
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (newUsers.length === 0) setIsLoading(false);
|
if (newUsers.length === 0) setIsLoading(false);
|
||||||
@@ -163,10 +202,10 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
|
|||||||
.then(({ data, status }) => {
|
.then(({ data, status }) => {
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
toast.success(
|
toast.success(
|
||||||
`Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize(
|
`Successfully generated${
|
||||||
type,
|
data.valid ? ` ${data.valid}/${informations.length}` : ""
|
||||||
)} codes and they have been notified by e-mail!`,
|
} ${capitalize(type)} codes and they have been notified by e-mail!`,
|
||||||
{ toastId: "success" },
|
{ toastId: "success" }
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -257,34 +296,41 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
|
|||||||
>
|
>
|
||||||
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||||
</Button>
|
</Button>
|
||||||
{user && (user.type === "developer" || user.type === "admin") && (
|
{user &&
|
||||||
<>
|
checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
||||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
<>
|
||||||
<label className="text-mti-gray-dim text-base font-normal">
|
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||||
Expiry Date
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
</label>
|
Expiry Date
|
||||||
<Checkbox
|
</label>
|
||||||
isChecked={isExpiryDateEnabled}
|
<Checkbox
|
||||||
onChange={setIsExpiryDateEnabled}
|
isChecked={isExpiryDateEnabled}
|
||||||
>
|
onChange={setIsExpiryDateEnabled}
|
||||||
Enabled
|
disabled={!!user.subscriptionExpirationDate}
|
||||||
</Checkbox>
|
>
|
||||||
</div>
|
Enabled
|
||||||
{isExpiryDateEnabled && (
|
</Checkbox>
|
||||||
<ReactDatePicker
|
</div>
|
||||||
className={clsx(
|
{isExpiryDateEnabled && (
|
||||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
<ReactDatePicker
|
||||||
"hover:border-mti-purple tooltip",
|
className={clsx(
|
||||||
"transition duration-300 ease-in-out",
|
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||||
)}
|
"hover:border-mti-purple tooltip",
|
||||||
filterDate={(date) => moment(date).isAfter(new Date())}
|
"transition duration-300 ease-in-out"
|
||||||
dateFormat="dd/MM/yyyy"
|
)}
|
||||||
selected={expiryDate}
|
filterDate={(date) =>
|
||||||
onChange={(date) => setExpiryDate(date)}
|
moment(date).isAfter(new Date()) &&
|
||||||
/>
|
(user.subscriptionExpirationDate
|
||||||
)}
|
? moment(date).isBefore(user.subscriptionExpirationDate)
|
||||||
</>
|
: true)
|
||||||
)}
|
}
|
||||||
|
dateFormat="dd/MM/yyyy"
|
||||||
|
selected={expiryDate}
|
||||||
|
onChange={(date) => setExpiryDate(date)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<label className="text-mti-gray-dim text-base font-normal">
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
Select the type of user they should be
|
Select the type of user they should be
|
||||||
</label>
|
</label>
|
||||||
@@ -295,9 +341,10 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
|
|||||||
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"
|
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"
|
||||||
>
|
>
|
||||||
{Object.keys(USER_TYPE_LABELS)
|
{Object.keys(USER_TYPE_LABELS)
|
||||||
.filter((x) =>
|
.filter((x) => {
|
||||||
USER_TYPE_PERMISSIONS[user.type].includes(x as Type),
|
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||||
)
|
return checkAccess(user, list, perm);
|
||||||
|
})
|
||||||
.map((type) => (
|
.map((type) => (
|
||||||
<option key={type} value={type}>
|
<option key={type} value={type}>
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||||
|
|||||||
@@ -1,126 +1,197 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||||
import {Type, User} from "@/interfaces/user";
|
import { Type, User } from "@/interfaces/user";
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize} from "lodash";
|
import { capitalize } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ReactDatePicker from "react-datepicker";
|
import ReactDatePicker from "react-datepicker";
|
||||||
import {toast} from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
|
import { checkAccess } from "@/utils/permissions";
|
||||||
|
import { PermissionType } from "@/interfaces/permissions";
|
||||||
|
|
||||||
const USER_TYPE_PERMISSIONS: {[key in Type]: Type[]} = {
|
const USER_TYPE_PERMISSIONS: {
|
||||||
student: [],
|
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
||||||
teacher: [],
|
} = {
|
||||||
agent: [],
|
student: {
|
||||||
corporate: ["student", "teacher"],
|
perm: "createCodeStudent",
|
||||||
admin: ["student", "teacher", "agent", "corporate", "admin"],
|
list: [],
|
||||||
developer: ["student", "teacher", "agent", "corporate", "admin", "developer"],
|
},
|
||||||
|
teacher: {
|
||||||
|
perm: "createCodeTeacher",
|
||||||
|
list: [],
|
||||||
|
},
|
||||||
|
agent: {
|
||||||
|
perm: "createCodeCountryManager",
|
||||||
|
list: [],
|
||||||
|
},
|
||||||
|
corporate: {
|
||||||
|
perm: "createCodeCorporate",
|
||||||
|
list: ["student", "teacher"],
|
||||||
|
},
|
||||||
|
mastercorporate: {
|
||||||
|
perm: undefined,
|
||||||
|
list: ["student", "teacher", "corporate"],
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
perm: "createCodeAdmin",
|
||||||
|
list: [
|
||||||
|
"student",
|
||||||
|
"teacher",
|
||||||
|
"agent",
|
||||||
|
"corporate",
|
||||||
|
"admin",
|
||||||
|
"mastercorporate",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
developer: {
|
||||||
|
perm: undefined,
|
||||||
|
list: [
|
||||||
|
"student",
|
||||||
|
"teacher",
|
||||||
|
"agent",
|
||||||
|
"corporate",
|
||||||
|
"admin",
|
||||||
|
"developer",
|
||||||
|
"mastercorporate",
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CodeGenerator({user}: {user: User}) {
|
export default function CodeGenerator({ user }: { user: User }) {
|
||||||
const [generatedCode, setGeneratedCode] = useState<string>();
|
const [generatedCode, setGeneratedCode] = useState<string>();
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
user?.subscriptionExpirationDate
|
||||||
const [type, setType] = useState<Type>("student");
|
? moment(user.subscriptionExpirationDate).toDate()
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
|
const [type, setType] = useState<Type>("student");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user && (user.type === "corporate" || user.type === "teacher")) {
|
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||||
setExpiryDate(user.subscriptionExpirationDate || null);
|
}, [isExpiryDateEnabled]);
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const generateCode = (type: Type) => {
|
||||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
const uid = new ShortUniqueId();
|
||||||
}, [isExpiryDateEnabled]);
|
const code = uid.randomUUID(6);
|
||||||
|
|
||||||
const generateCode = (type: Type) => {
|
axios
|
||||||
const uid = new ShortUniqueId();
|
.post("/api/code", { type, codes: [code], expiryDate })
|
||||||
const code = uid.randomUUID(6);
|
.then(({ data, status }) => {
|
||||||
|
if (data.ok) {
|
||||||
|
toast.success(`Successfully generated a ${capitalize(type)} code!`, {
|
||||||
|
toastId: "success",
|
||||||
|
});
|
||||||
|
setGeneratedCode(code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
axios
|
if (status === 403) {
|
||||||
.post("/api/code", {type, codes: [code], expiryDate})
|
toast.error(data.reason, { toastId: "forbidden" });
|
||||||
.then(({data, status}) => {
|
}
|
||||||
if (data.ok) {
|
})
|
||||||
toast.success(`Successfully generated a ${capitalize(type)} code!`, {toastId: "success"});
|
.catch(({ response: { status, data } }) => {
|
||||||
setGeneratedCode(code);
|
if (status === 403) {
|
||||||
return;
|
toast.error(data.reason, { toastId: "forbidden" });
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (status === 403) {
|
toast.error(`Something went wrong, please try again later!`, {
|
||||||
toast.error(data.reason, {toastId: "forbidden"});
|
toastId: "error",
|
||||||
}
|
});
|
||||||
})
|
});
|
||||||
.catch(({response: {status, data}}) => {
|
};
|
||||||
if (status === 403) {
|
|
||||||
toast.error(data.reason, {toastId: "forbidden"});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.error(`Something went wrong, please try again later!`, {toastId: "error"});
|
return (
|
||||||
});
|
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||||
};
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
User Code Generator
|
||||||
return (
|
</label>
|
||||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
{user && (
|
||||||
<label className="font-normal text-base text-mti-gray-dim">User Code Generator</label>
|
<select
|
||||||
{user && (
|
defaultValue="student"
|
||||||
<select
|
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||||
defaultValue="student"
|
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"
|
||||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
>
|
||||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
{Object.keys(USER_TYPE_LABELS)
|
||||||
{Object.keys(USER_TYPE_LABELS)
|
.filter((x) => {
|
||||||
.filter((x) => USER_TYPE_PERMISSIONS[user.type].includes(x as Type))
|
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||||
.map((type) => (
|
return checkAccess(user, list, perm);
|
||||||
<option key={type} value={type}>
|
})
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
.map((type) => (
|
||||||
</option>
|
<option key={type} value={type}>
|
||||||
))}
|
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||||
</select>
|
</option>
|
||||||
)}
|
))}
|
||||||
{user && (user.type === "developer" || user.type === "admin") && (
|
</select>
|
||||||
<>
|
)}
|
||||||
<div className="flex -md:flex-row md:flex-col -md:items-center 2xl:flex-row 2xl:items-center justify-between gap-2">
|
{user &&
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
|
checkAccess(user, ["developer", "admin", "corporate"]) && (
|
||||||
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled}>
|
<>
|
||||||
Enabled
|
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||||
</Checkbox>
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
</div>
|
Expiry Date
|
||||||
{isExpiryDateEnabled && (
|
</label>
|
||||||
<ReactDatePicker
|
<Checkbox
|
||||||
className={clsx(
|
isChecked={isExpiryDateEnabled}
|
||||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
onChange={setIsExpiryDateEnabled}
|
||||||
"hover:border-mti-purple tooltip",
|
disabled={!!user.subscriptionExpirationDate}
|
||||||
"transition duration-300 ease-in-out",
|
>
|
||||||
)}
|
Enabled
|
||||||
filterDate={(date) => moment(date).isAfter(new Date())}
|
</Checkbox>
|
||||||
dateFormat="dd/MM/yyyy"
|
</div>
|
||||||
selected={expiryDate}
|
{isExpiryDateEnabled && (
|
||||||
onChange={(date) => setExpiryDate(date)}
|
<ReactDatePicker
|
||||||
/>
|
className={clsx(
|
||||||
)}
|
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||||
</>
|
"hover:border-mti-purple tooltip",
|
||||||
)}
|
"transition duration-300 ease-in-out"
|
||||||
<Button onClick={() => generateCode(type)} disabled={isExpiryDateEnabled ? !expiryDate : false}>
|
)}
|
||||||
Generate
|
filterDate={(date) =>
|
||||||
</Button>
|
moment(date).isAfter(new Date()) &&
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Generated Code:</label>
|
(user.subscriptionExpirationDate
|
||||||
<div
|
? moment(date).isBefore(user.subscriptionExpirationDate)
|
||||||
className={clsx(
|
: true)
|
||||||
"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",
|
dateFormat="dd/MM/yyyy"
|
||||||
"transition duration-300 ease-in-out",
|
selected={expiryDate}
|
||||||
)}
|
onChange={(date) => setExpiryDate(date)}
|
||||||
data-tip="Click to copy"
|
/>
|
||||||
onClick={() => {
|
)}
|
||||||
if (generatedCode) navigator.clipboard.writeText(generatedCode);
|
</>
|
||||||
}}>
|
)}
|
||||||
{generatedCode}
|
<Button
|
||||||
</div>
|
onClick={() => generateCode(type)}
|
||||||
{generatedCode && <span className="text-sm text-mti-gray-dim font-light">Give this code to the user to complete their registration</span>}
|
disabled={isExpiryDateEnabled ? !expiryDate : false}
|
||||||
</div>
|
>
|
||||||
);
|
Generate
|
||||||
|
</Button>
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Generated Code:
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
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",
|
||||||
|
"transition duration-300 ease-in-out"
|
||||||
|
)}
|
||||||
|
data-tip="Click to copy"
|
||||||
|
onClick={() => {
|
||||||
|
if (generatedCode) navigator.clipboard.writeText(generatedCode);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{generatedCode}
|
||||||
|
</div>
|
||||||
|
{generatedCode && (
|
||||||
|
<span className="text-sm text-mti-gray-dim font-light">
|
||||||
|
Give this code to the user to complete their registration
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
358
src/pages/(admin)/Lists/CodeList.tsx
Normal file
358
src/pages/(admin)/Lists/CodeList.tsx
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
import useCodes from "@/hooks/useCodes";
|
||||||
|
import useUser from "@/hooks/useUser";
|
||||||
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import { Code, User } from "@/interfaces/user";
|
||||||
|
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||||
|
import {
|
||||||
|
createColumnHelper,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import axios from "axios";
|
||||||
|
import moment from "moment";
|
||||||
|
import { useEffect, useState, useMemo } from "react";
|
||||||
|
import { BsTrash } from "react-icons/bs";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import ReactDatePicker from "react-datepicker";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<Code>();
|
||||||
|
|
||||||
|
const CreatorCell = ({ id, users }: { id: string; users: User[] }) => {
|
||||||
|
const [creatorUser, setCreatorUser] = useState<User>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCreatorUser(users.find((x) => x.id === id));
|
||||||
|
}, [id, users]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{(creatorUser?.type === "corporate"
|
||||||
|
? creatorUser?.corporateInformation?.companyInformation?.name
|
||||||
|
: creatorUser?.name || "N/A") || "N/A"}{" "}
|
||||||
|
{creatorUser && `(${USER_TYPE_LABELS[creatorUser.type]})`}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CodeList({ user }: { user: User }) {
|
||||||
|
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const [filteredCorporate, setFilteredCorporate] = useState<User | undefined>(
|
||||||
|
user?.type === "corporate" ? user : undefined
|
||||||
|
);
|
||||||
|
const [filterAvailability, setFilterAvailability] = useState<
|
||||||
|
"in-use" | "unused"
|
||||||
|
>();
|
||||||
|
|
||||||
|
// const [filteredCodes, setFilteredCodes] = useState<Code[]>([]);
|
||||||
|
|
||||||
|
const { users } = useUsers();
|
||||||
|
const { codes, reload } = useCodes(
|
||||||
|
user?.type === "corporate" ? user?.id : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const [startDate, setStartDate] = useState<Date | null>(moment("01/01/2023").toDate());
|
||||||
|
const [endDate, setEndDate] = useState<Date | null>(moment().endOf("day").toDate());
|
||||||
|
const filteredCodes = useMemo(() => {
|
||||||
|
return codes.filter((x) => {
|
||||||
|
// TODO: if the expiry date is missing, it does not make sense to filter by date
|
||||||
|
// so we need to find a way to handle this edge case
|
||||||
|
if(startDate && endDate && x.expiryDate) {
|
||||||
|
const date = moment(x.expiryDate);
|
||||||
|
if(date.isBefore(startDate) || date.isAfter(endDate)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filteredCorporate && x.creator !== filteredCorporate.id) return false;
|
||||||
|
if (filterAvailability) {
|
||||||
|
if (filterAvailability === "in-use" && !x.userId) return false;
|
||||||
|
if (filterAvailability === "unused" && x.userId) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [codes, startDate, endDate, filteredCorporate, filterAvailability]);
|
||||||
|
|
||||||
|
const toggleCode = (id: string) => {
|
||||||
|
setSelectedCodes((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAllCodes = (checked: boolean) => {
|
||||||
|
if (checked)
|
||||||
|
return setSelectedCodes(
|
||||||
|
filteredCodes.filter((x) => !x.userId).map((x) => x.code)
|
||||||
|
);
|
||||||
|
|
||||||
|
return setSelectedCodes([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteCodes = async (codes: string[]) => {
|
||||||
|
if (
|
||||||
|
!confirm(`Are you sure you want to delete these ${codes.length} code(s)?`)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
codes.forEach((code) => params.append("code", code));
|
||||||
|
|
||||||
|
axios
|
||||||
|
.delete(`/api/code?${params.toString()}`)
|
||||||
|
.then(() => {
|
||||||
|
toast.success(`Deleted the codes!`);
|
||||||
|
setSelectedCodes([]);
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
if (reason.response.status === 404) {
|
||||||
|
toast.error("Code not found!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reason.response.status === 403) {
|
||||||
|
toast.error("You do not have permission to delete this code!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error("Something went wrong, please try again later.");
|
||||||
|
})
|
||||||
|
.finally(reload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteCode = async (code: Code) => {
|
||||||
|
if (!confirm(`Are you sure you want to delete this "${code.code}" code?`))
|
||||||
|
return;
|
||||||
|
|
||||||
|
axios
|
||||||
|
.delete(`/api/code/${code.code}`)
|
||||||
|
.then(() => toast.success(`Deleted the "${code.code}" exam`))
|
||||||
|
.catch((reason) => {
|
||||||
|
if (reason.response.status === 404) {
|
||||||
|
toast.error("Code not found!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reason.response.status === 403) {
|
||||||
|
toast.error("You do not have permission to delete this code!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error("Something went wrong, please try again later.");
|
||||||
|
})
|
||||||
|
.finally(reload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultColumns = [
|
||||||
|
columnHelper.accessor("code", {
|
||||||
|
id: "codeCheckbox",
|
||||||
|
header: () => (
|
||||||
|
<Checkbox
|
||||||
|
disabled={filteredCodes.filter((x) => !x.userId).length === 0}
|
||||||
|
isChecked={
|
||||||
|
selectedCodes.length ===
|
||||||
|
filteredCodes.filter((x) => !x.userId).length &&
|
||||||
|
filteredCodes.filter((x) => !x.userId).length > 0
|
||||||
|
}
|
||||||
|
onChange={(checked) => toggleAllCodes(checked)}
|
||||||
|
>
|
||||||
|
{""}
|
||||||
|
</Checkbox>
|
||||||
|
),
|
||||||
|
cell: (info) =>
|
||||||
|
!info.row.original.userId ? (
|
||||||
|
<Checkbox
|
||||||
|
isChecked={selectedCodes.includes(info.getValue())}
|
||||||
|
onChange={() => toggleCode(info.getValue())}
|
||||||
|
>
|
||||||
|
{""}
|
||||||
|
</Checkbox>
|
||||||
|
) : null,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("code", {
|
||||||
|
header: "Code",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("creationDate", {
|
||||||
|
header: "Creation Date",
|
||||||
|
cell: (info) =>
|
||||||
|
info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A",
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("email", {
|
||||||
|
header: "Invited E-mail",
|
||||||
|
cell: (info) => info.getValue() || "N/A",
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("creator", {
|
||||||
|
header: "Creator",
|
||||||
|
cell: (info) => <CreatorCell id={info.getValue()} users={users} />,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("userId", {
|
||||||
|
header: "Availability",
|
||||||
|
cell: (info) =>
|
||||||
|
info.getValue() ? (
|
||||||
|
<span className="flex gap-1 items-center text-mti-green">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-mti-green" /> In Use
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex gap-1 items-center text-mti-red">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-mti-red" /> Unused
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
header: "",
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }: { row: { original: Code } }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{!row.original.userId && (
|
||||||
|
<div
|
||||||
|
data-tip="Delete"
|
||||||
|
className="cursor-pointer tooltip"
|
||||||
|
onClick={() => deleteCode(row.original)}
|
||||||
|
>
|
||||||
|
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: filteredCodes,
|
||||||
|
columns: defaultColumns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between pb-4 pt-1">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Select
|
||||||
|
className="!w-96 !py-1"
|
||||||
|
disabled={user?.type === "corporate"}
|
||||||
|
isClearable
|
||||||
|
placeholder="Corporate"
|
||||||
|
value={
|
||||||
|
filteredCorporate
|
||||||
|
? {
|
||||||
|
label: `${
|
||||||
|
filteredCorporate.type === "corporate"
|
||||||
|
? filteredCorporate.corporateInformation
|
||||||
|
?.companyInformation?.name || filteredCorporate.name
|
||||||
|
: filteredCorporate.name
|
||||||
|
} (${USER_TYPE_LABELS[filteredCorporate.type]})`,
|
||||||
|
value: filteredCorporate.id,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
options={users
|
||||||
|
.filter((x) =>
|
||||||
|
["admin", "developer", "corporate"].includes(x.type)
|
||||||
|
)
|
||||||
|
.map((x) => ({
|
||||||
|
label: `${
|
||||||
|
x.type === "corporate"
|
||||||
|
? x.corporateInformation?.companyInformation?.name || x.name
|
||||||
|
: x.name
|
||||||
|
} (${USER_TYPE_LABELS[x.type]})`,
|
||||||
|
value: x.id,
|
||||||
|
user: x,
|
||||||
|
}))}
|
||||||
|
onChange={(value) =>
|
||||||
|
setFilteredCorporate(
|
||||||
|
value ? users.find((x) => x.id === value?.value) : undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
className="!w-96 !py-1"
|
||||||
|
placeholder="Availability"
|
||||||
|
isClearable
|
||||||
|
options={[
|
||||||
|
{ label: "In Use", value: "in-use" },
|
||||||
|
{ label: "Unused", value: "unused" },
|
||||||
|
]}
|
||||||
|
onChange={(value) =>
|
||||||
|
setFilterAvailability(
|
||||||
|
value ? (value.value as typeof filterAvailability) : undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ReactDatePicker
|
||||||
|
dateFormat="dd/MM/yyyy"
|
||||||
|
className="px-4 py-6 w-full 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
|
||||||
|
filterDate={(date: Date) =>
|
||||||
|
moment(date).isSameOrBefore(moment(new Date()))
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<span>{selectedCodes.length} code(s) selected</span>
|
||||||
|
<Button
|
||||||
|
disabled={selectedCodes.length === 0}
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
className="!py-1 px-10"
|
||||||
|
onClick={() => deleteCodes(selectedCodes)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</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 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
307
src/pages/(admin)/Lists/DiscountList.tsx
Normal file
307
src/pages/(admin)/Lists/DiscountList.tsx
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import useCodes from "@/hooks/useCodes";
|
||||||
|
import useDiscounts from "@/hooks/useDiscounts";
|
||||||
|
import useUser from "@/hooks/useUser";
|
||||||
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import {Discount} from "@/interfaces/paypal";
|
||||||
|
import {Code, User} from "@/interfaces/user";
|
||||||
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
|
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||||
|
import axios from "axios";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import moment from "moment";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import ReactDatePicker from "react-datepicker";
|
||||||
|
import {BsPencil, BsTrash} from "react-icons/bs";
|
||||||
|
import {toast} from "react-toastify";
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<Discount>();
|
||||||
|
|
||||||
|
const DiscountCreator = ({discount, onClose}: {discount?: Discount; onClose: () => void}) => {
|
||||||
|
const [percentage, setPercentage] = useState(discount?.percentage);
|
||||||
|
const [domain, setDomain] = useState(discount?.domain);
|
||||||
|
const [validUntil, setValidUntil] = useState(discount?.validUntil);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
const body = {percentage, domain, validUntil: validUntil?.toISOString() || undefined};
|
||||||
|
|
||||||
|
if (discount) {
|
||||||
|
return axios
|
||||||
|
.patch(`/api/discounts/${discount.id}`, body)
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Discount has been edited successfully!");
|
||||||
|
onClose();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Something went wrong, please try again later!");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return axios
|
||||||
|
.post(`/api/discounts`, body)
|
||||||
|
.then(() => {
|
||||||
|
toast.success("New discount has been created successfully!");
|
||||||
|
onClose();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Something went wrong, please try again later!");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 py-8">
|
||||||
|
<div className="w-full grid grid-cols-1 gap-8">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Domain *</label>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<Input
|
||||||
|
defaultValue={domain}
|
||||||
|
placeholder="encoach.com"
|
||||||
|
name="domain"
|
||||||
|
type="text"
|
||||||
|
onChange={(e) => setDomain(e.replaceAll("@", ""))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Percentage (in %) *</label>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<Input
|
||||||
|
defaultValue={percentage}
|
||||||
|
placeholder="20"
|
||||||
|
name="percentage"
|
||||||
|
type="number"
|
||||||
|
onChange={(e) => setPercentage(parseFloat(e))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Valid Until</label>
|
||||||
|
<div className="flex gap-4 items-center w-full">
|
||||||
|
<ReactDatePicker
|
||||||
|
wrapperClassName="w-full z-[900]"
|
||||||
|
calendarClassName="z-[900]"
|
||||||
|
popperClassName="z-[900]"
|
||||||
|
isClearable
|
||||||
|
className={clsx(
|
||||||
|
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||||
|
"hover:border-mti-purple tooltip",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
)}
|
||||||
|
filterDate={(date) => moment(date).isAfter(new Date())}
|
||||||
|
dateFormat="dd/MM/yyyy"
|
||||||
|
selected={validUntil}
|
||||||
|
onChange={(date) => setValidUntil(date ? moment(date).endOf("day").toDate() : undefined)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full justify-end items-center gap-8 mt-8">
|
||||||
|
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button className="w-full max-w-[200px]" onClick={submit} disabled={!percentage || !domain}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DiscountList({user}: {user: User}) {
|
||||||
|
const [selectedDiscounts, setSelectedDiscounts] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [editingDiscount, setEditingDiscount] = useState<Discount>();
|
||||||
|
|
||||||
|
const [filteredDiscounts, setFilteredDiscounts] = useState<Discount[]>([]);
|
||||||
|
|
||||||
|
const {users} = useUsers();
|
||||||
|
const {discounts, reload} = useDiscounts();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFilteredDiscounts(discounts);
|
||||||
|
}, [discounts]);
|
||||||
|
|
||||||
|
const toggleDiscount = (id: string) => {
|
||||||
|
setSelectedDiscounts((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAllDiscounts = (checked: boolean) => {
|
||||||
|
if (checked) return setSelectedDiscounts(filteredDiscounts.map((x) => x.id));
|
||||||
|
|
||||||
|
return setSelectedDiscounts([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteDiscounts = async (discounts: string[]) => {
|
||||||
|
if (!confirm(`Are you sure you want to delete these ${discounts.length} discount(s)?`)) return;
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
discounts.forEach((code) => params.append("discount", code));
|
||||||
|
|
||||||
|
axios
|
||||||
|
.delete(`/api/discounts?${params.toString()}`)
|
||||||
|
.then(() => toast.success(`Deleted the discount(s)!`))
|
||||||
|
.catch((reason) => {
|
||||||
|
if (reason.response.status === 404) {
|
||||||
|
toast.error("Discount not found!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reason.response.status === 403) {
|
||||||
|
toast.error("You do not have permission to delete this discount!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error("Something went wrong, please try again later.");
|
||||||
|
})
|
||||||
|
.finally(reload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteDiscount = async (discount: Discount) => {
|
||||||
|
if (!confirm(`Are you sure you want to delete this "${discount.id}" discount?`)) return;
|
||||||
|
|
||||||
|
axios
|
||||||
|
.delete(`/api/discounts/${discount.id}`)
|
||||||
|
.then(() => toast.success(`Deleted the "${discount.id}" discount`))
|
||||||
|
.catch((reason) => {
|
||||||
|
if (reason.response.status === 404) {
|
||||||
|
toast.error("Code not found!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reason.response.status === 403) {
|
||||||
|
toast.error("You do not have permission to delete this discount!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error("Something went wrong, please try again later.");
|
||||||
|
})
|
||||||
|
.finally(reload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultColumns = [
|
||||||
|
columnHelper.accessor("id", {
|
||||||
|
id: "id",
|
||||||
|
header: () => (
|
||||||
|
<Checkbox
|
||||||
|
disabled={filteredDiscounts.length === 0}
|
||||||
|
isChecked={selectedDiscounts.length === filteredDiscounts.length && filteredDiscounts.length > 0}
|
||||||
|
onChange={(checked) => toggleAllDiscounts(checked)}>
|
||||||
|
{""}
|
||||||
|
</Checkbox>
|
||||||
|
),
|
||||||
|
cell: (info) => (
|
||||||
|
<Checkbox isChecked={selectedDiscounts.includes(info.getValue())} onChange={() => toggleDiscount(info.getValue())}>
|
||||||
|
{""}
|
||||||
|
</Checkbox>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("id", {
|
||||||
|
header: "ID",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("domain", {
|
||||||
|
header: "Domain",
|
||||||
|
cell: (info) => `@${info.getValue()}`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("percentage", {
|
||||||
|
header: "Percentage",
|
||||||
|
cell: (info) => `${info.getValue()}%`,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("validUntil", {
|
||||||
|
header: "Valid Until",
|
||||||
|
cell: (info) => (info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : ""),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
header: "",
|
||||||
|
id: "actions",
|
||||||
|
cell: ({row}: {row: {original: Discount}}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div
|
||||||
|
data-tip="Delete"
|
||||||
|
className="cursor-pointer tooltip"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingDiscount(row.original);
|
||||||
|
}}>
|
||||||
|
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
|
</div>
|
||||||
|
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteDiscount(row.original)}>
|
||||||
|
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: filteredDiscounts,
|
||||||
|
columns: defaultColumns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setIsCreating(false);
|
||||||
|
setEditingDiscount(undefined);
|
||||||
|
reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
isOpen={isCreating || !!editingDiscount}
|
||||||
|
onClose={closeModal}
|
||||||
|
title={editingDiscount ? `Editing ${editingDiscount.id}` : "New Discount"}>
|
||||||
|
<DiscountCreator onClose={closeModal} discount={editingDiscount} />
|
||||||
|
</Modal>
|
||||||
|
<div className="flex items-center justify-end pb-4 pt-1">
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<span>{selectedDiscounts.length} code(s) selected</span>
|
||||||
|
<Button
|
||||||
|
disabled={selectedDiscounts.length === 0}
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
className="!py-1 px-10"
|
||||||
|
onClick={() => deleteDiscounts(selectedDiscounts)}>
|
||||||
|
Delete
|
||||||
|
</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 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>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreating(true)}
|
||||||
|
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white">
|
||||||
|
New Discount
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -72,7 +72,7 @@ export default function ExamList({user}: {user: User}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getTotalExercises = (exam: Exam) => {
|
const getTotalExercises = (exam: Exam) => {
|
||||||
if (exam.module === "reading" || exam.module === "listening") {
|
if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") {
|
||||||
return countExercises(exam.parts.flatMap((x) => x.exercises));
|
return countExercises(exam.parts.flatMap((x) => x.exercises));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,400 +3,335 @@ import Input from "@/components/Low/Input";
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import { Group, User } from "@/interfaces/user";
|
import {CorporateUser, Group, User} from "@/interfaces/user";
|
||||||
import {
|
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||||
createColumnHelper,
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
useReactTable,
|
|
||||||
} from "@tanstack/react-table";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { capitalize, uniq } from "lodash";
|
import {capitalize, uniq} from "lodash";
|
||||||
import { useEffect, useState } from "react";
|
import {useEffect, useState} from "react";
|
||||||
import { BsPencil, BsQuestionCircleFill, BsTrash } from "react-icons/bs";
|
import {BsPencil, BsQuestionCircleFill, BsTrash} from "react-icons/bs";
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
import { toast } from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import readXlsxFile from "read-excel-file";
|
import readXlsxFile from "read-excel-file";
|
||||||
import { useFilePicker } from "use-file-picker";
|
import {useFilePicker} from "use-file-picker";
|
||||||
|
import {getUserCorporate} from "@/utils/groups";
|
||||||
|
import { isAgentUser, isCorporateUser } from "@/resources/user";
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<Group>();
|
const columnHelper = createColumnHelper<Group>();
|
||||||
const EMAIL_REGEX = new RegExp(
|
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
||||||
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/,
|
|
||||||
);
|
|
||||||
|
|
||||||
interface CreateDialogProps {
|
const LinkedCorporate = ({userId, users, groups}: {userId: string, users: User[], groups: Group[]}) => {
|
||||||
user: User;
|
const [companyName, setCompanyName] = useState("");
|
||||||
users: User[];
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
group?: Group;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
useEffect(() => {
|
||||||
const [name, setName] = useState<string | undefined>(
|
const user = users.find((u) => u.id === userId)
|
||||||
group?.name || undefined,
|
if (!user) return setCompanyName("")
|
||||||
);
|
|
||||||
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
if (isCorporateUser(user)) return setCompanyName(user.corporateInformation?.companyInformation?.name || user.name)
|
||||||
const [participants, setParticipants] = useState<string[]>(
|
if (isAgentUser(user)) return setCompanyName(user.agentInformation?.companyName || user.name)
|
||||||
group?.participants || [],
|
|
||||||
);
|
const belongingGroups = groups.filter((x) => x.participants.includes(userId))
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const belongingGroupsAdmins = belongingGroups.map((x) => users.find((u) => u.id === x.admin)).filter((x) => !!x && isCorporateUser(x))
|
||||||
|
|
||||||
|
if (belongingGroupsAdmins.length === 0) return setCompanyName("")
|
||||||
|
|
||||||
|
const admin = (belongingGroupsAdmins[0] as CorporateUser)
|
||||||
|
setCompanyName(admin.corporateInformation?.companyInformation.name || admin.name)
|
||||||
|
}, [userId, users, groups]);
|
||||||
|
|
||||||
const { openFilePicker, filesContent, clear } = useFilePicker({
|
return isLoading ? <span className="animate-pulse">Loading...</span> : <>{companyName}</>;
|
||||||
accept: ".xlsx",
|
|
||||||
multiple: false,
|
|
||||||
readAs: "ArrayBuffer",
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (filesContent.length > 0) {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const file = filesContent[0];
|
|
||||||
readXlsxFile(file.content).then((rows) => {
|
|
||||||
const emails = uniq(
|
|
||||||
rows
|
|
||||||
.map((row) => {
|
|
||||||
const [email] = row as string[];
|
|
||||||
return EMAIL_REGEX.test(email) &&
|
|
||||||
!users.map((u) => u.email).includes(email)
|
|
||||||
? email.toString().trim()
|
|
||||||
: undefined;
|
|
||||||
})
|
|
||||||
.filter((x) => !!x),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (emails.length === 0) {
|
|
||||||
toast.error("Please upload an Excel file containing e-mails!");
|
|
||||||
clear();
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailUsers = [...new Set(emails)]
|
|
||||||
.map((x) => users.find((y) => y.email.toLowerCase() === x))
|
|
||||||
.filter((x) => x !== undefined);
|
|
||||||
const filteredUsers = emailUsers.filter(
|
|
||||||
(x) =>
|
|
||||||
((user.type === "developer" ||
|
|
||||||
user.type === "admin" ||
|
|
||||||
user.type === "corporate") &&
|
|
||||||
(x?.type === "student" || x?.type === "teacher")) ||
|
|
||||||
(user.type === "teacher" && x?.type === "student"),
|
|
||||||
);
|
|
||||||
|
|
||||||
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
|
|
||||||
toast.success(
|
|
||||||
user.type !== "teacher"
|
|
||||||
? "Added all teachers and students found in the file you've provided!"
|
|
||||||
: "Added all students found in the file you've provided!",
|
|
||||||
{ toastId: "upload-success" },
|
|
||||||
);
|
|
||||||
setIsLoading(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [filesContent, user.type, users]);
|
|
||||||
|
|
||||||
const submit = () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
if (name !== group?.name && (name === "Students" || name === "Teachers")) {
|
|
||||||
toast.error(
|
|
||||||
"That group name is reserved and cannot be used, please enter another one.",
|
|
||||||
);
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
(group ? axios.patch : axios.post)(
|
|
||||||
group ? `/api/groups/${group.id}` : "/api/groups",
|
|
||||||
{ name, admin, participants },
|
|
||||||
)
|
|
||||||
.then(() => {
|
|
||||||
toast.success(
|
|
||||||
`Group "${name}" ${group ? "edited" : "created"} successfully`,
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Something went wrong, please try again later!");
|
|
||||||
return false;
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setIsLoading(false);
|
|
||||||
onClose();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-4 flex w-full flex-col gap-12 px-4 py-2">
|
|
||||||
<div className="flex flex-col gap-8">
|
|
||||||
<Input
|
|
||||||
name="name"
|
|
||||||
type="text"
|
|
||||||
label="Name"
|
|
||||||
defaultValue={name}
|
|
||||||
onChange={setName}
|
|
||||||
required
|
|
||||||
disabled={group?.disableEditing}
|
|
||||||
/>
|
|
||||||
<div className="flex w-full flex-col gap-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="text-mti-gray-dim text-base font-normal">
|
|
||||||
Participants
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
className="tooltip"
|
|
||||||
data-tip="The Excel file should only include a column with the desired e-mails."
|
|
||||||
>
|
|
||||||
<BsQuestionCircleFill />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full gap-8">
|
|
||||||
<Select
|
|
||||||
className="w-full"
|
|
||||||
value={participants.map((x) => ({
|
|
||||||
value: x,
|
|
||||||
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
|
||||||
}))}
|
|
||||||
placeholder="Participants..."
|
|
||||||
defaultValue={participants.map((x) => ({
|
|
||||||
value: x,
|
|
||||||
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
|
||||||
}))}
|
|
||||||
options={users
|
|
||||||
.filter((x) =>
|
|
||||||
user.type === "teacher"
|
|
||||||
? x.type === "student"
|
|
||||||
: x.type === "student" || x.type === "teacher",
|
|
||||||
)
|
|
||||||
.map((x) => ({ value: x.id, label: `${x.email} - ${x.name}` }))}
|
|
||||||
onChange={(value) => setParticipants(value.map((x) => x.value))}
|
|
||||||
isMulti
|
|
||||||
isSearchable
|
|
||||||
menuPortalTarget={document?.body}
|
|
||||||
styles={{
|
|
||||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
|
||||||
control: (styles) => ({
|
|
||||||
...styles,
|
|
||||||
backgroundColor: "white",
|
|
||||||
borderRadius: "999px",
|
|
||||||
padding: "1rem 1.5rem",
|
|
||||||
zIndex: "40",
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{user.type !== "teacher" && (
|
|
||||||
<Button
|
|
||||||
className="w-full max-w-[300px]"
|
|
||||||
onClick={openFilePicker}
|
|
||||||
isLoading={isLoading}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{filesContent.length === 0
|
|
||||||
? "Upload participants Excel file"
|
|
||||||
: filesContent[0].name}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-8 flex w-full items-center justify-end gap-8">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
color="red"
|
|
||||||
className="w-full max-w-[200px]"
|
|
||||||
isLoading={isLoading}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="w-full max-w-[200px]"
|
|
||||||
onClick={submit}
|
|
||||||
isLoading={isLoading}
|
|
||||||
disabled={!name}
|
|
||||||
>
|
|
||||||
Submit
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const filterTypes = ["corporate", "teacher"];
|
interface CreateDialogProps {
|
||||||
|
user: User;
|
||||||
export default function GroupList({ user }: { user: User }) {
|
users: User[];
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
group?: Group;
|
||||||
const [editingGroup, setEditingGroup] = useState<Group>();
|
onClose: () => void;
|
||||||
const [filterByUser, setFilterByUser] = useState(false);
|
}
|
||||||
|
|
||||||
const { users } = useUsers();
|
const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
||||||
const { groups, reload } = useGroups(
|
const [name, setName] = useState<string | undefined>(group?.name || undefined);
|
||||||
user && filterTypes.includes(user?.type) ? user.id : undefined,
|
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
||||||
);
|
const [participants, setParticipants] = useState<string[]>(group?.participants || []);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
useEffect(() => {
|
|
||||||
if (user && (user.type === "corporate" || user.type === "teacher")) {
|
const {openFilePicker, filesContent, clear} = useFilePicker({
|
||||||
setFilterByUser(true);
|
accept: ".xlsx",
|
||||||
}
|
multiple: false,
|
||||||
}, [user]);
|
readAs: "ArrayBuffer",
|
||||||
|
});
|
||||||
const deleteGroup = (group: Group) => {
|
|
||||||
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
|
useEffect(() => {
|
||||||
|
if (filesContent.length > 0) {
|
||||||
axios
|
setIsLoading(true);
|
||||||
.delete<{ ok: boolean }>(`/api/groups/${group.id}`)
|
|
||||||
.then(() => toast.success(`Group "${group.name}" deleted successfully`))
|
const file = filesContent[0];
|
||||||
.catch(() => toast.error("Something went wrong, please try again later!"))
|
readXlsxFile(file.content).then((rows) => {
|
||||||
.finally(reload);
|
const emails = uniq(
|
||||||
};
|
rows
|
||||||
|
.map((row) => {
|
||||||
const defaultColumns = [
|
const [email] = row as string[];
|
||||||
columnHelper.accessor("id", {
|
return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email) ? email.toString().trim() : undefined;
|
||||||
header: "ID",
|
})
|
||||||
cell: (info) => info.getValue(),
|
.filter((x) => !!x),
|
||||||
}),
|
);
|
||||||
columnHelper.accessor("name", {
|
|
||||||
header: "Name",
|
if (emails.length === 0) {
|
||||||
cell: (info) => info.getValue(),
|
toast.error("Please upload an Excel file containing e-mails!");
|
||||||
}),
|
clear();
|
||||||
columnHelper.accessor("admin", {
|
setIsLoading(false);
|
||||||
header: "Admin",
|
return;
|
||||||
cell: (info) => (
|
}
|
||||||
<div
|
|
||||||
className="tooltip"
|
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
|
||||||
data-tip={capitalize(
|
const filteredUsers = emailUsers.filter(
|
||||||
users.find((x) => x.id === info.getValue())?.type,
|
(x) =>
|
||||||
)}
|
((user.type === "developer" || user.type === "admin" || user.type === "corporate" || user.type === "mastercorporate") &&
|
||||||
>
|
(x?.type === "student" || x?.type === "teacher")) ||
|
||||||
{users.find((x) => x.id === info.getValue())?.name}
|
(user.type === "teacher" && x?.type === "student"),
|
||||||
</div>
|
);
|
||||||
),
|
|
||||||
}),
|
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
|
||||||
columnHelper.accessor("participants", {
|
toast.success(
|
||||||
header: "Participants",
|
user.type !== "teacher"
|
||||||
cell: (info) =>
|
? "Added all teachers and students found in the file you've provided!"
|
||||||
info
|
: "Added all students found in the file you've provided!",
|
||||||
.getValue()
|
{toastId: "upload-success"},
|
||||||
.map((x) => users.find((y) => y.id === x)?.name)
|
);
|
||||||
.join(", "),
|
setIsLoading(false);
|
||||||
}),
|
});
|
||||||
{
|
}
|
||||||
header: "",
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
id: "actions",
|
}, [filesContent, user.type, users]);
|
||||||
cell: ({ row }: { row: { original: Group } }) => {
|
|
||||||
return (
|
const submit = () => {
|
||||||
<>
|
setIsLoading(true);
|
||||||
{user &&
|
|
||||||
(user.type === "developer" ||
|
if (name !== group?.name && (name === "Students" || name === "Teachers")) {
|
||||||
user.type === "admin" ||
|
toast.error("That group name is reserved and cannot be used, please enter another one.");
|
||||||
user.id === row.original.admin) && (
|
setIsLoading(false);
|
||||||
<div className="flex gap-2">
|
return;
|
||||||
{(!row.original.disableEditing ||
|
}
|
||||||
["developer", "admin"].includes(user.type)) && (
|
|
||||||
<div
|
(group ? axios.patch : axios.post)(group ? `/api/groups/${group.id}` : "/api/groups", {name, admin, participants})
|
||||||
data-tip="Edit"
|
.then(() => {
|
||||||
className="tooltip cursor-pointer"
|
toast.success(`Group "${name}" ${group ? "edited" : "created"} successfully`);
|
||||||
onClick={() => setEditingGroup(row.original)}
|
return true;
|
||||||
>
|
})
|
||||||
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
.catch(() => {
|
||||||
</div>
|
toast.error("Something went wrong, please try again later!");
|
||||||
)}
|
return false;
|
||||||
{(!row.original.disableEditing ||
|
})
|
||||||
["developer", "admin"].includes(user.type)) && (
|
.finally(() => {
|
||||||
<div
|
setIsLoading(false);
|
||||||
data-tip="Delete"
|
onClose();
|
||||||
className="tooltip cursor-pointer"
|
});
|
||||||
onClick={() => deleteGroup(row.original)}
|
};
|
||||||
>
|
|
||||||
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
return (
|
||||||
</div>
|
<div className="mt-4 flex w-full flex-col gap-12 px-4 py-2">
|
||||||
)}
|
<div className="flex flex-col gap-8">
|
||||||
</div>
|
<Input name="name" type="text" label="Name" defaultValue={name} onChange={setName} required disabled={group?.disableEditing} />
|
||||||
)}
|
<div className="flex w-full flex-col gap-3">
|
||||||
</>
|
<div className="flex items-center gap-2">
|
||||||
);
|
<label className="text-mti-gray-dim text-base font-normal">Participants</label>
|
||||||
},
|
<div className="tooltip" data-tip="The Excel file should only include a column with the desired e-mails.">
|
||||||
},
|
<BsQuestionCircleFill />
|
||||||
];
|
</div>
|
||||||
|
</div>
|
||||||
const table = useReactTable({
|
<div className="flex w-full gap-8">
|
||||||
data: groups,
|
<Select
|
||||||
columns: defaultColumns,
|
className="w-full"
|
||||||
getCoreRowModel: getCoreRowModel(),
|
value={participants.map((x) => ({
|
||||||
});
|
value: x,
|
||||||
|
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
||||||
const closeModal = () => {
|
}))}
|
||||||
setIsCreating(false);
|
placeholder="Participants..."
|
||||||
setEditingGroup(undefined);
|
defaultValue={participants.map((x) => ({
|
||||||
reload();
|
value: x,
|
||||||
};
|
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
||||||
|
}))}
|
||||||
return (
|
options={users
|
||||||
<div className="h-full w-full rounded-xl">
|
.filter((x) => (user.type === "teacher" ? x.type === "student" : x.type === "student" || x.type === "teacher"))
|
||||||
<Modal
|
.map((x) => ({value: x.id, label: `${x.email} - ${x.name}`}))}
|
||||||
isOpen={isCreating || !!editingGroup}
|
onChange={(value) => setParticipants(value.map((x) => x.value))}
|
||||||
onClose={closeModal}
|
isMulti
|
||||||
title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}
|
isSearchable
|
||||||
>
|
menuPortalTarget={document?.body}
|
||||||
<CreatePanel
|
styles={{
|
||||||
group={editingGroup}
|
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||||
user={user}
|
control: (styles) => ({
|
||||||
onClose={closeModal}
|
...styles,
|
||||||
users={
|
backgroundColor: "white",
|
||||||
user?.type === "corporate" || user?.type === "teacher"
|
borderRadius: "999px",
|
||||||
? users.filter(
|
padding: "1rem 1.5rem",
|
||||||
(u) =>
|
zIndex: "40",
|
||||||
groups
|
}),
|
||||||
.filter((g) => g.admin === user.id)
|
}}
|
||||||
.flatMap((g) => g.participants)
|
/>
|
||||||
.includes(u.id) ||
|
{user.type !== "teacher" && (
|
||||||
groups.flatMap((g) => g.participants).includes(u.id),
|
<Button className="w-full max-w-[300px]" onClick={openFilePicker} isLoading={isLoading} variant="outline">
|
||||||
)
|
{filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name}
|
||||||
: users
|
</Button>
|
||||||
}
|
)}
|
||||||
/>
|
</div>
|
||||||
</Modal>
|
</div>
|
||||||
<table className="bg-mti-purple-ultralight/40 w-full rounded-xl">
|
</div>
|
||||||
<thead>
|
<div className="mt-8 flex w-full items-center justify-end gap-8">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
<Button variant="outline" color="red" className="w-full max-w-[200px]" isLoading={isLoading} onClick={onClose}>
|
||||||
<tr key={headerGroup.id}>
|
Cancel
|
||||||
{headerGroup.headers.map((header) => (
|
</Button>
|
||||||
<th className="py-4" key={header.id}>
|
<Button className="w-full max-w-[200px]" onClick={submit} isLoading={isLoading} disabled={!name}>
|
||||||
{header.isPlaceholder
|
Submit
|
||||||
? null
|
</Button>
|
||||||
: flexRender(
|
</div>
|
||||||
header.column.columnDef.header,
|
</div>
|
||||||
header.getContext(),
|
);
|
||||||
)}
|
};
|
||||||
</th>
|
|
||||||
))}
|
const filterTypes = ["corporate", "teacher", "mastercorporate"];
|
||||||
</tr>
|
|
||||||
))}
|
export default function GroupList({user}: {user: User}) {
|
||||||
</thead>
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
<tbody className="px-2">
|
const [editingGroup, setEditingGroup] = useState<Group>();
|
||||||
{table.getRowModel().rows.map((row) => (
|
const [filterByUser, setFilterByUser] = useState(false);
|
||||||
<tr
|
|
||||||
className="even:bg-mti-purple-ultralight/40 rounded-lg py-2 odd:bg-white"
|
const {users} = useUsers();
|
||||||
key={row.id}
|
const {groups, reload} = useGroups(user && filterTypes.includes(user?.type) ? user.id : undefined, user?.type);
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
useEffect(() => {
|
||||||
<td className="px-4 py-2" key={cell.id}>
|
if (user && (['corporate', 'teacher', 'mastercorporate'].includes(user.type))) {
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
setFilterByUser(true);
|
||||||
</td>
|
}
|
||||||
))}
|
}, [user]);
|
||||||
</tr>
|
|
||||||
))}
|
const deleteGroup = (group: Group) => {
|
||||||
</tbody>
|
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
|
||||||
</table>
|
|
||||||
|
axios
|
||||||
<button
|
.delete<{ok: boolean}>(`/api/groups/${group.id}`)
|
||||||
onClick={() => setIsCreating(true)}
|
.then(() => toast.success(`Group "${group.name}" deleted successfully`))
|
||||||
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out"
|
.catch(() => toast.error("Something went wrong, please try again later!"))
|
||||||
>
|
.finally(reload);
|
||||||
New Group
|
};
|
||||||
</button>
|
|
||||||
</div>
|
const defaultColumns = [
|
||||||
);
|
columnHelper.accessor("id", {
|
||||||
|
header: "ID",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("name", {
|
||||||
|
header: "Name",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("admin", {
|
||||||
|
header: "Admin",
|
||||||
|
cell: (info) => (
|
||||||
|
<div className="tooltip" data-tip={capitalize(users.find((x) => x.id === info.getValue())?.type)}>
|
||||||
|
{users.find((x) => x.id === info.getValue())?.name}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("admin", {
|
||||||
|
header: "Linked Corporate",
|
||||||
|
cell: (info) => <LinkedCorporate userId={info.getValue()} users={users} groups={groups} />,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("participants", {
|
||||||
|
header: "Participants",
|
||||||
|
cell: (info) =>
|
||||||
|
info
|
||||||
|
.getValue()
|
||||||
|
.map((x) => users.find((y) => y.id === x)?.name)
|
||||||
|
.join(", "),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
header: "",
|
||||||
|
id: "actions",
|
||||||
|
cell: ({row}: {row: {original: Group}}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{user && (user.type === "developer" || user.type === "admin" || user.id === row.original.admin) && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(!row.original.disableEditing || ["developer", "admin"].includes(user.type)) && (
|
||||||
|
<div data-tip="Edit" className="tooltip cursor-pointer" onClick={() => setEditingGroup(row.original)}>
|
||||||
|
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(!row.original.disableEditing || ["developer", "admin"].includes(user.type)) && (
|
||||||
|
<div data-tip="Delete" className="tooltip cursor-pointer" onClick={() => deleteGroup(row.original)}>
|
||||||
|
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: groups,
|
||||||
|
columns: defaultColumns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setIsCreating(false);
|
||||||
|
setEditingGroup(undefined);
|
||||||
|
reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full rounded-xl">
|
||||||
|
<Modal isOpen={isCreating || !!editingGroup} onClose={closeModal} title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}>
|
||||||
|
<CreatePanel
|
||||||
|
group={editingGroup}
|
||||||
|
user={user}
|
||||||
|
onClose={closeModal}
|
||||||
|
users={
|
||||||
|
user?.type === "corporate" || user?.type === "teacher"
|
||||||
|
? users.filter(
|
||||||
|
(u) =>
|
||||||
|
groups
|
||||||
|
.filter((g) => g.admin === user.id)
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(u.id) || groups.flatMap((g) => g.participants).includes(u.id),
|
||||||
|
)
|
||||||
|
: users
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
<table className="bg-mti-purple-ultralight/40 w-full rounded-xl">
|
||||||
|
<thead>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<th className="py-4" 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="even:bg-mti-purple-ultralight/40 rounded-lg py-2 odd:bg-white" 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>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreating(true)}
|
||||||
|
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out">
|
||||||
|
New Group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
|||||||
const [unit, setUnit] = useState<DurationUnit>(pack?.duration_unit || "months");
|
const [unit, setUnit] = useState<DurationUnit>(pack?.duration_unit || "months");
|
||||||
|
|
||||||
const [price, setPrice] = useState(pack?.price || 0);
|
const [price, setPrice] = useState(pack?.price || 0);
|
||||||
const [currency, setCurrency] = useState<string>(pack?.currency || "EUR");
|
const [currency, setCurrency] = useState<string>(pack?.currency || "OMR");
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
(pack ? axios.patch : axios.post)(pack ? `/api/packages/${pack.id}` : "/api/packages", {
|
(pack ? axios.patch : axios.post)(pack ? `/api/packages/${pack.id}` : "/api/packages", {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user