Compare commits
95 Commits
ENCOA-38/a
...
settings-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80939d16a5 | ||
|
|
11b5490af4 | ||
|
|
a31070d4a3 | ||
|
|
2a58e0d33f | ||
|
|
afe59f5a3a | ||
|
|
7fd56357e0 | ||
|
|
a4a40b9145 | ||
|
|
309dfba583 | ||
|
|
cf64a91651 | ||
|
|
0f47a8af70 | ||
|
|
d0310f7c2b | ||
|
|
f6a0a391b9 | ||
|
|
8dd4dad096 | ||
|
|
96baa2a6e0 | ||
|
|
8dd557a29b | ||
|
|
4e30eda06f | ||
|
|
12bb124d91 | ||
|
|
a71e6632d6 | ||
|
|
36f518afca | ||
|
|
a534126c61 | ||
|
|
752a46b247 | ||
|
|
663b1aae4f | ||
|
|
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 |
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
|
||||||
|
|||||||
8235
package-lock.json
generated
8235
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@beam-australia/react-env": "^3.1.1",
|
"@beam-australia/react-env": "^3.1.1",
|
||||||
"@dnd-kit/core": "^6.1.0",
|
"@dnd-kit/core": "^6.1.0",
|
||||||
|
"@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",
|
||||||
@@ -19,10 +20,12 @@
|
|||||||
"@paypal/paypal-js": "^7.1.0",
|
"@paypal/paypal-js": "^7.1.0",
|
||||||
"@paypal/react-paypal-js": "^8.1.3",
|
"@paypal/react-paypal-js": "^8.1.3",
|
||||||
"@react-pdf/renderer": "^3.1.14",
|
"@react-pdf/renderer": "^3.1.14",
|
||||||
|
"@react-spring/web": "^9.7.4",
|
||||||
"@tanstack/react-table": "^8.10.1",
|
"@tanstack/react-table": "^8.10.1",
|
||||||
"@types/node": "18.13.0",
|
"@types/node": "18.13.0",
|
||||||
"@types/react": "18.0.27",
|
"@types/react": "18.0.27",
|
||||||
"@types/react-dom": "18.0.10",
|
"@types/react-dom": "18.0.10",
|
||||||
|
"@use-gesture/react": "^10.3.1",
|
||||||
"axios": "^1.3.5",
|
"axios": "^1.3.5",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"chart.js": "^4.2.1",
|
"chart.js": "^4.2.1",
|
||||||
@@ -67,9 +70,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",
|
||||||
@@ -95,7 +99,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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 = `
|
||||||
|
Encoach's 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. Encoach<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: "Encoach is uncertain about this text. If Encoach had to classify it, it would be considered",
|
||||||
|
human: "Encoach is uncertain about this text. If Encoach had to classify it, it would likely be considered",
|
||||||
|
mixed: "Encoach is uncertain about this text. If Encoach had to classify it, it would likely be a"
|
||||||
|
},
|
||||||
|
medium: {
|
||||||
|
ai: "Encoach is moderately confident this text was",
|
||||||
|
human: "Encoach is moderately confident this text is entirely",
|
||||||
|
mixed: "Encoach is moderately confident this text is a"
|
||||||
|
},
|
||||||
|
high: {
|
||||||
|
ai: "Encoach is highly confident this text was",
|
||||||
|
human: "Encoach is highly confident this text is entirely",
|
||||||
|
mixed: "Encoach 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">Encoach 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;
|
||||||
84
src/components/Dropdown.tsx
Normal file
84
src/components/Dropdown.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import React, { useState, ReactNode, useRef, useEffect } from 'react';
|
||||||
|
import { animated, useSpring } from '@react-spring/web';
|
||||||
|
|
||||||
|
interface DropdownProps {
|
||||||
|
title: ReactNode;
|
||||||
|
open?: boolean;
|
||||||
|
className?: string;
|
||||||
|
contentWrapperClassName?: string;
|
||||||
|
bottomPadding?: number;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Dropdown: React.FC<DropdownProps> = ({
|
||||||
|
title,
|
||||||
|
open = false,
|
||||||
|
className = "w-full text-left font-semibold flex justify-between items-center p-4",
|
||||||
|
contentWrapperClassName = "px-6",
|
||||||
|
bottomPadding = 12,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState<boolean>(open);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [contentHeight, setContentHeight] = useState<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
if (contentRef.current) {
|
||||||
|
resizeObserver = new ResizeObserver(entries => {
|
||||||
|
for (let entry of entries) {
|
||||||
|
if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
|
||||||
|
const height = entry.borderBoxSize[0].blockSize;
|
||||||
|
setContentHeight(height + bottomPadding);
|
||||||
|
} else {
|
||||||
|
// Fallback for browsers that don't support borderBoxSize
|
||||||
|
const height = entry.contentRect.height;
|
||||||
|
setContentHeight(height + bottomPadding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(contentRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [bottomPadding]);
|
||||||
|
|
||||||
|
const springProps = useSpring({
|
||||||
|
height: isOpen ? contentHeight : 0,
|
||||||
|
opacity: isOpen ? 1 : 0,
|
||||||
|
config: { tension: 300, friction: 30 }
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 transform transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<animated.div style={springProps} className="overflow-hidden">
|
||||||
|
<div ref={contentRef} className={contentWrapperClassName} style={{paddingBottom: bottomPadding}}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</animated.div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dropdown;
|
||||||
@@ -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 w-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,
|
||||||
@@ -111,10 +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(() => {
|
|
||||||
if (updateIndex) updateIndex(questionIndex);
|
|
||||||
}, [questionIndex, updateIndex]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) {
|
if (hasExamEnded) {
|
||||||
const answer = {
|
const answer = {
|
||||||
@@ -154,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]);
|
||||||
@@ -185,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">
|
||||||
@@ -204,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" && (
|
||||||
@@ -283,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"
|
||||||
|
|||||||
@@ -87,10 +87,6 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log(answers);
|
|
||||||
}, [answers]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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";
|
||||||
|
|
||||||
@@ -14,13 +15,22 @@ function Question({
|
|||||||
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">
|
||||||
{isNaN(Number(id)) ? (
|
{isNaN(Number(id)) ? (
|
||||||
<span className="">{prompt}</span>
|
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||||
) : (
|
) : (
|
||||||
<span className="">
|
<span className="">
|
||||||
{id} - {prompt}
|
<>
|
||||||
|
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||||
|
</>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-4 justify-between">
|
<div className="flex flex-wrap gap-4 justify-between">
|
||||||
@@ -55,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);
|
||||||
@@ -83,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}]);
|
||||||
|
|||||||
@@ -1,31 +1,34 @@
|
|||||||
import {SpeakingExercise} from "@/interfaces/exam";
|
import { SpeakingExercise } from "@/interfaces/exam";
|
||||||
import {CommonProps} from ".";
|
import { CommonProps } from ".";
|
||||||
import {Fragment, useEffect, useState} from "react";
|
import { Fragment, useEffect, useState } from "react";
|
||||||
import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs";
|
import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {downloadBlob} from "@/utils/evaluation";
|
import { downloadBlob } from "@/utils/evaluation";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
|
||||||
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 [inputText, setInputText] = useState("");
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
const saveToStorage = async () => {
|
const saveToStorage = async () => {
|
||||||
if (mediaBlob && mediaBlob.startsWith("blob")) {
|
if (mediaBlob && mediaBlob.startsWith("blob")) {
|
||||||
const blobBuffer = await downloadBlob(mediaBlob);
|
const blobBuffer = await downloadBlob(mediaBlob);
|
||||||
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
|
const audioFile = new File([blobBuffer], "audio.wav", { type: "audio/wav" });
|
||||||
|
|
||||||
const seed = Math.random().toString().replace("0.", "");
|
const seed = Math.random().toString().replace("0.", "");
|
||||||
|
|
||||||
@@ -39,8 +42,8 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await axios.post<{path: string}>("/api/storage/insert", formData, config);
|
const response = await axios.post<{ path: string }>("/api/storage/insert", formData, config);
|
||||||
if (audioURL) await axios.post("/api/storage/delete", {path: audioURL});
|
if (audioURL) await axios.post("/api/storage/delete", { path: audioURL });
|
||||||
return response.data.path;
|
return response.data.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +52,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userSolutions.length > 0) {
|
if (userSolutions.length > 0) {
|
||||||
const {solution} = userSolutions[0] as {solution?: string};
|
const { solution } = userSolutions[0] as { solution?: string };
|
||||||
if (solution && !mediaBlob) setMediaBlob(solution);
|
if (solution && !mediaBlob) setMediaBlob(solution);
|
||||||
if (solution && !solution.startsWith("blob")) setAudioURL(solution);
|
if (solution && !solution.startsWith("blob")) setAudioURL(solution);
|
||||||
}
|
}
|
||||||
@@ -74,36 +77,66 @@ 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: 0, 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: 0, total: 100, missing: 0},
|
score: { correct: 0, total: 100, missing: 0 },
|
||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNoteWriting = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const newText = e.target.value;
|
||||||
|
const words = newText.match(/\S+/g);
|
||||||
|
const wordCount = words ? words.length : 0;
|
||||||
|
|
||||||
|
if (wordCount <= 100) {
|
||||||
|
setInputText(newText);
|
||||||
|
} else {
|
||||||
|
let count = 0;
|
||||||
|
let lastIndex = 0;
|
||||||
|
const matches = newText.matchAll(/\S+/g);
|
||||||
|
for (const match of matches) {
|
||||||
|
count++;
|
||||||
|
if (count > 100) break;
|
||||||
|
lastIndex = match.index! + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInputText(newText.slice(0, lastIndex));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
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 1 minute and 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 +148,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,25 +156,28 @@ 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>
|
||||||
|
|
||||||
|
{prompts && prompts.length > 0 && (
|
||||||
|
<div className="w-full h-full flex flex-col gap-4">
|
||||||
|
<textarea
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
className="w-full h-full min-h-[200px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
||||||
|
onChange={handleNoteWriting}
|
||||||
|
value={inputText}
|
||||||
|
placeholder="Write your notes here..."
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<span className="text-base self-end text-mti-gray-cool">Word Count: {(inputText.match(/\S+/g) || []).length}/100</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ReactMediaRecorder
|
<ReactMediaRecorder
|
||||||
audio
|
audio
|
||||||
onStop={(blob) => setMediaBlob(blob)}
|
onStop={(blob) => setMediaBlob(blob)}
|
||||||
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
|
render={({ status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl }) => (
|
||||||
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
<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">
|
||||||
|
|||||||
@@ -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
|
||||||
? {
|
? {
|
||||||
|
|||||||
168
src/components/InfiniteCarousel.tsx
Normal file
168
src/components/InfiniteCarousel.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import React, { useRef, useEffect, useState, useCallback, ReactNode } from 'react';
|
||||||
|
import { useSpring, animated } from '@react-spring/web';
|
||||||
|
import { useDrag } from '@use-gesture/react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
interface InfiniteCarouselProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
height: string;
|
||||||
|
speed?: number;
|
||||||
|
gap?: number;
|
||||||
|
overlay?: ReactNode;
|
||||||
|
overlayFunc?: (index: number) => void;
|
||||||
|
overlayClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InfiniteCarousel: React.FC<InfiniteCarouselProps> = ({
|
||||||
|
children,
|
||||||
|
height,
|
||||||
|
speed = 20000,
|
||||||
|
gap = 16,
|
||||||
|
overlay = undefined,
|
||||||
|
overlayFunc = undefined,
|
||||||
|
overlayClassName = ""
|
||||||
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [containerWidth, setContainerWidth] = useState<number>(0);
|
||||||
|
const itemCount = React.Children.count(children);
|
||||||
|
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||||
|
const [itemWidth, setItemWidth] = useState<number>(0);
|
||||||
|
const [isInfinite, setIsInfinite] = useState<boolean>(true);
|
||||||
|
const dragStartX = useRef<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const containerWidth = containerRef.current.clientWidth;
|
||||||
|
setContainerWidth(containerWidth);
|
||||||
|
|
||||||
|
const firstChild = containerRef.current.firstElementChild?.firstElementChild as HTMLElement;
|
||||||
|
if (firstChild) {
|
||||||
|
const childWidth = firstChild.offsetWidth;
|
||||||
|
setItemWidth(childWidth);
|
||||||
|
|
||||||
|
const totalContentWidth = (childWidth + gap) * itemCount - gap;
|
||||||
|
setIsInfinite(totalContentWidth > containerWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleResize();
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
}, [gap, itemCount]);
|
||||||
|
|
||||||
|
const totalWidth = (itemWidth + gap) * itemCount;
|
||||||
|
|
||||||
|
const [{ x }, api] = useSpring(() => ({
|
||||||
|
from: { x: 0 },
|
||||||
|
to: { x: -totalWidth },
|
||||||
|
config: { duration: speed },
|
||||||
|
loop: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const startAnimation = useCallback(() => {
|
||||||
|
if (isInfinite) {
|
||||||
|
api.start({
|
||||||
|
from: { x: x.get() },
|
||||||
|
to: { x: x.get() - totalWidth },
|
||||||
|
config: { duration: speed },
|
||||||
|
loop: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
api.stop();
|
||||||
|
api.start({ x: 0, immediate: true });
|
||||||
|
}
|
||||||
|
}, [api, x, totalWidth, speed, isInfinite]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerWidth > 0 && !isDragging) {
|
||||||
|
startAnimation();
|
||||||
|
}
|
||||||
|
}, [containerWidth, isDragging, startAnimation]);
|
||||||
|
|
||||||
|
const bind = useDrag(({ down, movement: [mx], first }) => {
|
||||||
|
if (!isInfinite) return;
|
||||||
|
if (first) {
|
||||||
|
setIsDragging(true);
|
||||||
|
api.stop();
|
||||||
|
dragStartX.current = x.get();
|
||||||
|
}
|
||||||
|
if (down) {
|
||||||
|
let newX = dragStartX.current + mx;
|
||||||
|
newX = ((newX % totalWidth) + totalWidth) % totalWidth;
|
||||||
|
if (newX > 0) newX -= totalWidth;
|
||||||
|
api.start({ x: newX, immediate: true });
|
||||||
|
} else {
|
||||||
|
setIsDragging(false);
|
||||||
|
startAnimation();
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
filterTaps: true,
|
||||||
|
from: () => [x.get(), 0],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="overflow-hidden relative select-none"
|
||||||
|
style={{ height, touchAction: 'pan-y' }}
|
||||||
|
ref={containerRef}
|
||||||
|
{...(isInfinite ? bind() : {})}
|
||||||
|
>
|
||||||
|
<animated.div
|
||||||
|
className="flex"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
willChange: 'transform',
|
||||||
|
transform: isInfinite
|
||||||
|
? x.to((x) => `translate3d(${x}px, 0, 0)`)
|
||||||
|
: 'none',
|
||||||
|
gap: `${gap}px`,
|
||||||
|
width: 'fit-content',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{React.Children.map(children, (child, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex-shrink-0 relative"
|
||||||
|
>
|
||||||
|
{overlay !== undefined && overlayFunc !== undefined && (
|
||||||
|
<div className={clsx('absolute', overlayClassName)} onClick={() => overlayFunc(i)}>
|
||||||
|
{overlay}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="select-none"
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
{child}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isInfinite && React.Children.map(children, (child, i) => (
|
||||||
|
<div
|
||||||
|
key={`clone-${i}`}
|
||||||
|
className="flex-shrink-0 relative"
|
||||||
|
>
|
||||||
|
{overlay !== undefined && overlayFunc !== undefined && (
|
||||||
|
<div className={clsx('absolute', overlayClassName)} onClick={() => overlayFunc(i)}>
|
||||||
|
{overlay}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="select-none"
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
{child}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</animated.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InfiniteCarousel;
|
||||||
@@ -4,7 +4,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
24
src/components/ModuleBadge.tsx
Normal file
24
src/components/ModuleBadge.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
|
||||||
|
|
||||||
|
const ModuleBadge: React.FC<{ module: string; level?: number }> = ({ module, level }) => (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
|
||||||
|
module === "reading" && "bg-ielts-reading",
|
||||||
|
module === "listening" && "bg-ielts-listening",
|
||||||
|
module === "writing" && "bg-ielts-writing",
|
||||||
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
|
module === "level" && "bg-ielts-level",
|
||||||
|
)}>
|
||||||
|
{module === "reading" && <BsBook className="w-4 h-4" />}
|
||||||
|
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
||||||
|
{module === "writing" && <BsPen className="w-4 h-4" />}
|
||||||
|
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
||||||
|
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
||||||
|
{/* do not switch to level && it will convert the 0.0 to 0*/}
|
||||||
|
{level !== undefined && (<span className="text-sm">{level.toFixed(1)}</span>)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ModuleBadge;
|
||||||
@@ -1,118 +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 router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||||
|
|
||||||
const expirationDateColor = (date: Date) => {
|
const expirationDateColor = (date: Date) => {
|
||||||
const momentDate = moment(date);
|
const momentDate = moment(date);
|
||||||
const today = moment(new Date());
|
const today = moment(new Date());
|
||||||
|
|
||||||
if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light";
|
if (today.add(1, "days").isAfter(momentDate))
|
||||||
if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light";
|
return "!bg-mti-red-ultralight border-mti-red-light";
|
||||||
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light";
|
if (today.add(3, "days").isAfter(momentDate))
|
||||||
};
|
return "!bg-mti-rose-ultralight border-mti-rose-light";
|
||||||
|
if (today.add(7, "days").isAfter(momentDate))
|
||||||
|
return "!bg-mti-orange-ultralight border-mti-orange-light";
|
||||||
|
};
|
||||||
|
|
||||||
const showExpirationDate = () => {
|
const showExpirationDate = () => {
|
||||||
if (!user.subscriptionExpirationDate) return false;
|
if (!user.subscriptionExpirationDate) return false;
|
||||||
|
|
||||||
const momentDate = moment(user.subscriptionExpirationDate);
|
const momentDate = moment(user.subscriptionExpirationDate);
|
||||||
const today = moment(new Date());
|
const today = moment(new Date());
|
||||||
|
|
||||||
return today.add(7, "days").isAfter(momentDate);
|
return today.add(7, "days").isAfter(momentDate);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user.type !== "student" && user.type !== "teacher") return setDisablePaymentPage(false);
|
if (user.type !== "student" && user.type !== "teacher")
|
||||||
isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result));
|
return setDisablePaymentPage(false);
|
||||||
}, [user]);
|
isUserFromCorporate(user.id).then((result) =>
|
||||||
|
setDisablePaymentPage(result)
|
||||||
|
);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
return (
|
const badges = [
|
||||||
<>
|
{
|
||||||
<Modal isOpen={isTicketOpen} onClose={() => setIsTicketOpen(false)} title="Submit a ticket">
|
module: "reading",
|
||||||
<TicketSubmission user={user} page={router.asPath} onClose={() => setIsTicketOpen(false)} />
|
icon: () => <BsBook className="h-4 w-4 text-white" />,
|
||||||
</Modal>
|
achieved: user.levels.reading >= user.desiredLevels.reading,
|
||||||
|
},
|
||||||
|
|
||||||
{user && (
|
{
|
||||||
<MobileMenu disableNavigation={disableNavigation} path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />
|
module: "listening",
|
||||||
)}
|
icon: () => <BsHeadphones className="h-4 w-4 text-white" />,
|
||||||
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
|
achieved: user.levels.listening >= user.desiredLevels.listening,
|
||||||
<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>
|
module: "writing",
|
||||||
</Link>
|
icon: () => <BsPen className="h-4 w-4 text-white" />,
|
||||||
<div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6">
|
achieved: user.levels.writing >= user.desiredLevels.writing,
|
||||||
{/* OPEN TICKET SYSTEM */}
|
},
|
||||||
<button
|
{
|
||||||
className={clsx(
|
module: "speaking",
|
||||||
"border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1",
|
icon: () => <BsMegaphone className="h-4 w-4 text-white" />,
|
||||||
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white z-20",
|
achieved: user.levels.speaking >= user.desiredLevels.speaking,
|
||||||
)}
|
},
|
||||||
data-tip="Submit a help/feedback ticket"
|
{
|
||||||
onClick={() => setIsTicketOpen(true)}>
|
module: "level",
|
||||||
<BsQuestionCircleFill />
|
icon: () => <BsClipboard className="h-4 w-4 text-white" />,
|
||||||
</button>
|
achieved: user.levels.level >= user.desiredLevels.level,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
{showExpirationDate() && (
|
return (
|
||||||
<Link
|
<>
|
||||||
href={!!user.subscriptionExpirationDate && !disablePaymentPage ? "/payment" : ""}
|
<Modal
|
||||||
data-tip="Expiry date"
|
isOpen={isTicketOpen}
|
||||||
className={clsx(
|
onClose={() => setIsTicketOpen(false)}
|
||||||
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none",
|
title="Submit a ticket"
|
||||||
"tooltip tooltip-bottom transition duration-300 ease-in-out",
|
>
|
||||||
!user.subscriptionExpirationDate
|
<TicketSubmission
|
||||||
? "bg-mti-green-ultralight border-mti-green-light"
|
user={user}
|
||||||
: expirationDateColor(user.subscriptionExpirationDate),
|
page={router.asPath}
|
||||||
"border-mti-gray-platinum bg-white",
|
onClose={() => setIsTicketOpen(false)}
|
||||||
)}>
|
/>
|
||||||
{!user.subscriptionExpirationDate && "Unlimited"}
|
</Modal>
|
||||||
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
|
||||||
</Link>
|
{user && (
|
||||||
)}
|
<MobileMenu
|
||||||
<Link href={disableNavigation ? "" : "/profile"} className="-md:hidden flex items-center justify-end gap-6">
|
disableNavigation={disableNavigation}
|
||||||
<img src={user.profilePicture} alt={user.name} className="h-10 w-10 rounded-full object-cover" />
|
path={path}
|
||||||
<span className="-md:hidden text-right">
|
isOpen={isMenuOpen}
|
||||||
{user.type === "corporate" ? `${user.corporateInformation?.companyInformation.name} |` : ""} {user.name} |{" "}
|
onClose={() => setIsMenuOpen(false)}
|
||||||
{USER_TYPE_LABELS[user.type]}
|
user={user}
|
||||||
</span>
|
/>
|
||||||
</Link>
|
)}
|
||||||
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
|
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
|
||||||
<BsList className="text-mti-purple-light h-8 w-8" />
|
<Link
|
||||||
</div>
|
href={disableNavigation ? "" : "/"}
|
||||||
</div>
|
className=" flex items-center gap-8 md:px-8"
|
||||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
>
|
||||||
</header>
|
<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,138 +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) => {
|
|
||||||
setIsLoading(false);
|
|
||||||
return data.id;
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
setIsLoading(false);
|
|
||||||
return err;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onApprove = async (data: OnApproveData, actions: OnApproveActions) => {
|
|
||||||
if (!trackingId) {
|
|
||||||
throw new Error("trackingId is not set");
|
|
||||||
}
|
|
||||||
|
|
||||||
axios
|
|
||||||
.post<{ ok: boolean; reason?: string }>("/api/paypal/approve", {
|
|
||||||
id: data.orderID,
|
|
||||||
duration,
|
|
||||||
duration_unit,
|
|
||||||
trackingId,
|
|
||||||
})
|
|
||||||
.then((request) => {
|
|
||||||
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);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
toast.error("Something went wrong, please try again later");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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;
|
|
||||||
}
|
|
||||||
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,7 +12,9 @@ import {
|
|||||||
BsCloudFill,
|
BsCloudFill,
|
||||||
BsCurrencyDollar,
|
BsCurrencyDollar,
|
||||||
BsClipboardData,
|
BsClipboardData,
|
||||||
|
BsFileLock,
|
||||||
} from "react-icons/bs";
|
} from "react-icons/bs";
|
||||||
|
import { CiDumbbell } from "react-icons/ci";
|
||||||
import {RiLogoutBoxFill} from "react-icons/ri";
|
import {RiLogoutBoxFill} from "react-icons/ri";
|
||||||
import {SlPencil} from "react-icons/sl";
|
import {SlPencil} from "react-icons/sl";
|
||||||
import {FaAward} from "react-icons/fa";
|
import {FaAward} from "react-icons/fa";
|
||||||
@@ -23,16 +25,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,12 +74,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);
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
axios.post("/api/logout").finally(() => {
|
axios.post("/api/logout").finally(() => {
|
||||||
@@ -96,33 +98,22 @@ 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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{(userType || "") !== 'agent' && (
|
{checkAccess(user, ["student", "teacher", "developer"], "viewExercises") && (
|
||||||
<>
|
<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} />
|
|
||||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" 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, getTypesOfUser(["agent"]), "viewRecords") && (
|
||||||
|
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={isMinimized} />
|
||||||
|
)}
|
||||||
|
{checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"], "viewPaymentRecords") && (
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
Icon={BsCurrencyDollar}
|
Icon={BsCurrencyDollar}
|
||||||
@@ -132,7 +123,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}
|
||||||
@@ -142,7 +133,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}
|
||||||
@@ -153,32 +144,65 @@ 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} />
|
||||||
{(userType || "") !== 'agent' && (
|
{checkAccess(user, getTypesOfUser(["agent"]), "viewStats") && (
|
||||||
<>
|
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
|
||||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
|
|
||||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" 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(["agent"]), "viewRecords") && (
|
||||||
|
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" 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
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function InteractiveSpeaking({
|
|||||||
onBack,
|
onBack,
|
||||||
}: InteractiveSpeakingExercise & CommonProps) {
|
}: InteractiveSpeakingExercise & CommonProps) {
|
||||||
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
|
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
|
||||||
const [diffNumber, setDiffNumber] = useState<0 | 1 | 2 | 3>(0);
|
const [diffNumber, setDiffNumber] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
|
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
|
||||||
@@ -115,13 +115,13 @@ export default function InteractiveSpeaking({
|
|||||||
{userSolutions &&
|
{userSolutions &&
|
||||||
userSolutions.length > 0 &&
|
userSolutions.length > 0 &&
|
||||||
userSolutions[0].evaluation &&
|
userSolutions[0].evaluation &&
|
||||||
userSolutions[0].evaluation[`transcript_${(index + 1) as 1 | 2 | 3}`] &&
|
userSolutions[0].evaluation[`transcript_${(index + 1)}`] &&
|
||||||
userSolutions[0].evaluation[`fixed_text_${(index + 1) as 1 | 2 | 3}`] && (
|
userSolutions[0].evaluation[`fixed_text_${(index + 1)}`] && (
|
||||||
<Button
|
<Button
|
||||||
className="w-full max-w-[180px] !py-2 self-center"
|
className="w-full max-w-[180px] !py-2 self-center"
|
||||||
color="pink"
|
color="pink"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setDiffNumber((index + 1) as 1 | 2 | 3)}>
|
onClick={() => setDiffNumber((index + 1))}>
|
||||||
View Correction
|
View Correction
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -132,16 +132,32 @@ 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 > 0 ? (
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
||||||
|
<Tab
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
General Feedback
|
||||||
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
className={({selected}) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
@@ -153,59 +169,49 @@ export default function InteractiveSpeaking({
|
|||||||
}>
|
}>
|
||||||
Evaluation
|
Evaluation
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
{Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).map((key, index) => (
|
||||||
className={({selected}) =>
|
<Tab
|
||||||
clsx(
|
key={key}
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
className={({ selected }) =>
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
clsx(
|
||||||
"transition duration-300 ease-in-out",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
)
|
"transition duration-300 ease-in-out",
|
||||||
}>
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||||
Recommended Answer (Prompt 1)
|
)
|
||||||
</Tab>
|
}>
|
||||||
<Tab
|
Recommended Answer<br />(Prompt {index + 1})
|
||||||
className={({selected}) =>
|
</Tab>
|
||||||
clsx(
|
))}
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
Recommended Answer (Prompt 2)
|
|
||||||
</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",
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
Recommended Answer (Prompt 3)
|
|
||||||
</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">
|
||||||
|
<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">
|
||||||
|
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit")} key={key}>
|
||||||
|
{key}: Level {grade}
|
||||||
|
</div>
|
||||||
|
{typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</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">{userSolutions[0].evaluation!.comment}</span>
|
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
{Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).map((key, index) => (
|
||||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
<Tab.Panel key={key} className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
{userSolutions[0].evaluation!.perfect_answer_1!.replaceAll(/\s{2,}/g, "\n\n")}
|
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||||
</span>
|
{userSolutions[0].evaluation![`perfect_answer_${(index + 1)}`].answer.replaceAll(/\s{2,}/g, "\n\n")}
|
||||||
</Tab.Panel>
|
</span>
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
</Tab.Panel>
|
||||||
<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")}
|
|
||||||
</span>
|
|
||||||
</Tab.Panel>
|
|
||||||
<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">
|
|
||||||
{userSolutions[0].evaluation!.perfect_answer_3!.replaceAll(/\s{2,}/g, "\n\n")}
|
|
||||||
</span>
|
|
||||||
</Tab.Panel>
|
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
/* 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";
|
||||||
|
|
||||||
@@ -13,6 +15,13 @@ function Question({
|
|||||||
options,
|
options,
|
||||||
userSolution,
|
userSolution,
|
||||||
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) {
|
||||||
|
const 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";
|
||||||
@@ -28,10 +37,12 @@ function Question({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
{isNaN(Number(id)) ? (
|
{isNaN(Number(id)) ? (
|
||||||
<span className="">{prompt}</span>
|
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||||
) : (
|
) : (
|
||||||
<span className="">
|
<span className="">
|
||||||
{id} - {prompt}
|
<>
|
||||||
|
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||||
|
</>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-4 gap-4 place-items-center">
|
<div className="grid grid-cols-4 gap-4 place-items-center">
|
||||||
@@ -61,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;
|
||||||
@@ -83,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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -99,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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -126,14 +126,19 @@ 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) ? (
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
||||||
<Tab
|
<Tab
|
||||||
@@ -145,10 +150,21 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||||
)
|
)
|
||||||
}>
|
}>
|
||||||
|
General Feedback
|
||||||
|
</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",
|
||||||
|
)
|
||||||
|
}>
|
||||||
Evaluation
|
Evaluation
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
className={({selected}) =>
|
className={({ selected }) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
@@ -160,9 +176,29 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
</Tab>
|
</Tab>
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels>
|
<Tab.Panels>
|
||||||
|
{/* General Feedback */}
|
||||||
|
<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">
|
||||||
|
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit")} key={key}>
|
||||||
|
{key}: Level {grade}
|
||||||
|
</div>
|
||||||
|
{typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Tab.Panel>
|
||||||
|
{/* Evaluation */}
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
|
{/* Recommended Answer */}
|
||||||
<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 &&
|
{userSolutions[0].evaluation!.perfect_answer &&
|
||||||
@@ -191,7 +227,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
onBack({
|
onBack({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: userSolutions,
|
solutions: userSolutions,
|
||||||
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
score: { total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 },
|
||||||
type,
|
type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,15 +123,31 @@ 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>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
|
||||||
|
<Tab
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
General Feedback
|
||||||
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
className={({selected}) =>
|
className={({selected}) =>
|
||||||
clsx(
|
clsx(
|
||||||
@@ -148,16 +170,54 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
}>
|
}>
|
||||||
Recommended Answer
|
Recommended Answer
|
||||||
</Tab>
|
</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>
|
||||||
|
{/* Global */}
|
||||||
|
<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">
|
||||||
|
<div className={clsx("bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 w-fit")} key={key}>
|
||||||
|
{key}: Level {grade}
|
||||||
|
</div>
|
||||||
|
{typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Tab.Panel>
|
||||||
|
{/* Evaluation */}
|
||||||
<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">
|
||||||
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
|
{/* Recommended Answer */}
|
||||||
<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">
|
||||||
<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.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>
|
||||||
|
{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":
|
||||||
|
|||||||
289
src/components/StatGridItem.tsx
Normal file
289
src/components/StatGridItem.tsx
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BsClock, BsXCircle } from 'react-icons/bs';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { Stat, User } from '@/interfaces/user';
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import ai_usage from "@/utils/ai.detection";
|
||||||
|
import { calculateBandScore } from "@/utils/score";
|
||||||
|
import moment from 'moment';
|
||||||
|
import { Assignment } from '@/interfaces/results';
|
||||||
|
import { uuidv4 } from "@firebase/util";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { uniqBy } from "lodash";
|
||||||
|
import { sortByModule } from "@/utils/moduleUtils";
|
||||||
|
import { convertToUserSolutions } from "@/utils/stats";
|
||||||
|
import { getExamById } from "@/utils/exams";
|
||||||
|
import { Exam, UserSolution } from '@/interfaces/exam';
|
||||||
|
import ModuleBadge from './ModuleBadge';
|
||||||
|
|
||||||
|
const formatTimestamp = (timestamp: string | number) => {
|
||||||
|
const time = typeof timestamp === "string" ? parseInt(timestamp) : timestamp;
|
||||||
|
const date = moment(time);
|
||||||
|
const formatter = "YYYY/MM/DD - HH:mm";
|
||||||
|
return date.format(formatter);
|
||||||
|
};
|
||||||
|
|
||||||
|
const aggregateScoresByModule = (stats: Stat[]): { module: Module; total: number; missing: number; correct: number }[] => {
|
||||||
|
const scores: {
|
||||||
|
[key in Module]: { total: number; missing: number; correct: number };
|
||||||
|
} = {
|
||||||
|
reading: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
listening: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
writing: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
speaking: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
level: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
stats.forEach((x) => {
|
||||||
|
scores[x.module!] = {
|
||||||
|
total: scores[x.module!].total + x.score.total,
|
||||||
|
correct: scores[x.module!].correct + x.score.correct,
|
||||||
|
missing: scores[x.module!].missing + x.score.missing,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.keys(scores)
|
||||||
|
.filter((x) => scores[x as Module].total > 0)
|
||||||
|
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StatsGridItemProps {
|
||||||
|
width?: string | undefined;
|
||||||
|
height?: string | undefined;
|
||||||
|
examNumber?: number | undefined;
|
||||||
|
stats: Stat[];
|
||||||
|
timestamp: string | number;
|
||||||
|
user: User,
|
||||||
|
assignments: Assignment[];
|
||||||
|
users: User[];
|
||||||
|
training?: boolean,
|
||||||
|
selectedTrainingExams?: string[];
|
||||||
|
maxTrainingExams?: number;
|
||||||
|
setSelectedTrainingExams?: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
|
setExams: (exams: Exam[]) => void;
|
||||||
|
setShowSolutions: (show: boolean) => void;
|
||||||
|
setUserSolutions: (solutions: UserSolution[]) => void;
|
||||||
|
setSelectedModules: (modules: Module[]) => void;
|
||||||
|
setInactivity: (inactivity: number) => void;
|
||||||
|
setTimeSpent: (time: number) => void;
|
||||||
|
renderPdfIcon: (session: string, color: string, textColor: string) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatsGridItem: React.FC<StatsGridItemProps> = ({
|
||||||
|
stats,
|
||||||
|
timestamp,
|
||||||
|
user,
|
||||||
|
assignments,
|
||||||
|
users,
|
||||||
|
training,
|
||||||
|
selectedTrainingExams,
|
||||||
|
setSelectedTrainingExams,
|
||||||
|
setExams,
|
||||||
|
setShowSolutions,
|
||||||
|
setUserSolutions,
|
||||||
|
setSelectedModules,
|
||||||
|
setInactivity,
|
||||||
|
setTimeSpent,
|
||||||
|
renderPdfIcon,
|
||||||
|
width = undefined,
|
||||||
|
height = undefined,
|
||||||
|
examNumber = undefined,
|
||||||
|
maxTrainingExams = undefined
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
||||||
|
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
||||||
|
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
||||||
|
const assignmentID = stats.reduce((_, current) => current.assignment as any, "");
|
||||||
|
const assignment = assignments.find((a) => a.id === assignmentID);
|
||||||
|
const isDisabled = stats.some((x) => x.isDisabled);
|
||||||
|
|
||||||
|
const aiUsage = Math.round(ai_usage(stats) * 100);
|
||||||
|
|
||||||
|
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||||
|
module: x.module,
|
||||||
|
level: calculateBandScore(x.correct, x.total, x.module, user.focus),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const textColor = clsx(
|
||||||
|
correct / total >= 0.7 && "text-mti-purple",
|
||||||
|
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||||
|
correct / total < 0.3 && "text-mti-rose",
|
||||||
|
);
|
||||||
|
|
||||||
|
const { timeSpent, inactivity, session } = stats[0];
|
||||||
|
|
||||||
|
const selectExam = () => {
|
||||||
|
if (training && !isDisabled && typeof maxTrainingExams !== "undefined" && typeof setSelectedTrainingExams !== "undefined" && typeof timestamp == "string") {
|
||||||
|
setSelectedTrainingExams(prevExams => {
|
||||||
|
const uniqueExams = [...new Set(stats.map(stat => `${stat.module}-${stat.date}`))];
|
||||||
|
const indexes = uniqueExams.map(exam => prevExams.indexOf(exam)).filter(index => index !== -1);
|
||||||
|
if (indexes.length > 0) {
|
||||||
|
const newExams = [...prevExams];
|
||||||
|
indexes.sort((a, b) => b - a).forEach(index => {
|
||||||
|
newExams.splice(index, 1);
|
||||||
|
});
|
||||||
|
return newExams;
|
||||||
|
} else {
|
||||||
|
if (prevExams.length + uniqueExams.length <= maxTrainingExams) {
|
||||||
|
return [...prevExams, ...uniqueExams];
|
||||||
|
} else {
|
||||||
|
return prevExams;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const examPromises = uniqBy(stats, "exam").map((stat) => {
|
||||||
|
return getExamById(stat.module, stat.exam);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDisabled) return;
|
||||||
|
|
||||||
|
Promise.all(examPromises).then((exams) => {
|
||||||
|
if (exams.every((x) => !!x)) {
|
||||||
|
if (!!timeSpent) setTimeSpent(timeSpent);
|
||||||
|
if (!!inactivity) setInactivity(inactivity);
|
||||||
|
setUserSolutions(convertToUserSolutions(stats));
|
||||||
|
setShowSolutions(true);
|
||||||
|
setExams(exams.map((x) => x!).sort(sortByModule));
|
||||||
|
setSelectedModules(
|
||||||
|
exams
|
||||||
|
.map((x) => x!)
|
||||||
|
.sort(sortByModule)
|
||||||
|
.map((x) => x!.module),
|
||||||
|
);
|
||||||
|
router.push("/exercises");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
|
||||||
|
<div className="flex flex-col md:gap-1 -md:gap-2 2xl:gap-2">
|
||||||
|
<span className="font-medium">{formatTimestamp(timestamp)}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!!timeSpent && (
|
||||||
|
<span className="text-sm flex gap-2 items-center tooltip" data-tip="Time Spent">
|
||||||
|
<BsClock /> {Math.floor(timeSpent / 60)} minutes
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!!inactivity && (
|
||||||
|
<span className="text-sm flex gap-2 items-center tooltip" data-tip="Inactivity">
|
||||||
|
<BsXCircle /> {Math.floor(inactivity / 60)} minutes
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
<span className={textColor}>
|
||||||
|
Level{" "}
|
||||||
|
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
{renderPdfIcon(session, textColor, textColor)}
|
||||||
|
</div>
|
||||||
|
{examNumber === undefined ? (
|
||||||
|
<>
|
||||||
|
{aiUsage >= 50 && user.type !== "student" && (
|
||||||
|
<div className={clsx(
|
||||||
|
"ml-auto border px-1 rounded w-fit mr-1",
|
||||||
|
{
|
||||||
|
'bg-orange-100 border-orange-400 text-orange-700': aiUsage < 80,
|
||||||
|
'bg-red-100 border-red-400 text-red-700': aiUsage >= 80,
|
||||||
|
}
|
||||||
|
)}>
|
||||||
|
<span className="text-xs">AI Usage</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className='flex justify-end'>
|
||||||
|
<span className="font-semibold bg-gray-200 text-gray-800 px-2.5 py-0.5 rounded-full mt-0.5">{examNumber}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full flex flex-col gap-1">
|
||||||
|
<div className={clsx(
|
||||||
|
"grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2",
|
||||||
|
examNumber !== undefined && "pr-10"
|
||||||
|
)}>
|
||||||
|
{aggregatedLevels.map(({ module, level }) => (
|
||||||
|
<ModuleBadge key={module} module={module} level={level} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{assignment && (
|
||||||
|
<span className="font-light text-sm">
|
||||||
|
Assignment: {assignment.name}, Teacher: {users.find((u) => u.id === assignment.assigner)?.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
key={uuidv4()}
|
||||||
|
className={clsx(
|
||||||
|
"flex flex-col justify-between gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden",
|
||||||
|
isDisabled && "grayscale tooltip",
|
||||||
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
|
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||||
|
correct / total < 0.3 && "hover:border-mti-rose",
|
||||||
|
typeof selectedTrainingExams !== "undefined" && typeof timestamp === "string" && selectedTrainingExams.some(exam => exam.includes(timestamp)) && "border-2 border-slate-600",
|
||||||
|
)}
|
||||||
|
onClick={examNumber === undefined ? selectExam : undefined}
|
||||||
|
style={{
|
||||||
|
...(width !== undefined && { width }),
|
||||||
|
...(height !== undefined && { height }),
|
||||||
|
}}
|
||||||
|
data-tip="This exam is still being evaluated..."
|
||||||
|
role="button">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
key={uuidv4()}
|
||||||
|
className={clsx(
|
||||||
|
"flex flex-col gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:tooltip md:hidden",
|
||||||
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
|
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||||
|
correct / total < 0.3 && "hover:border-mti-rose",
|
||||||
|
)}
|
||||||
|
data-tip="Your screen size is too small to view previous exams."
|
||||||
|
style={{
|
||||||
|
...(width !== undefined && { width }),
|
||||||
|
...(height !== undefined && { height }),
|
||||||
|
}}
|
||||||
|
role="button">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatsGridItem;
|
||||||
23
src/components/TrainingContent/AnimatedHighlight.tsx
Normal file
23
src/components/TrainingContent/AnimatedHighlight.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
const HighlightedContent: React.FC<{ html: string; highlightPhrases: string[] }> = ({ html, highlightPhrases }) => {
|
||||||
|
|
||||||
|
const createHighlightedContent = useCallback(() => {
|
||||||
|
if (highlightPhrases.length === 0) {
|
||||||
|
return { __html: html };
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeRegExp = (string: string) => {
|
||||||
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
};
|
||||||
|
|
||||||
|
const regex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'gi');
|
||||||
|
const highlightedHtml = html.replace(regex, (match) => `<span style="background-color: yellow;">${match}</span>`);
|
||||||
|
|
||||||
|
return { __html: highlightedHtml };
|
||||||
|
}, [html, highlightPhrases]);
|
||||||
|
|
||||||
|
return <div dangerouslySetInnerHTML={createHighlightedContent()} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HighlightedContent;
|
||||||
91
src/components/TrainingContent/Exercise.tsx
Normal file
91
src/components/TrainingContent/Exercise.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import React, { useState, useCallback } from "react";
|
||||||
|
import ExerciseWalkthrough from "@/training/ExerciseWalkthrough";
|
||||||
|
import { ITrainingTip, WalkthroughConfigs } from "./TrainingInterfaces";
|
||||||
|
|
||||||
|
|
||||||
|
// This wrapper is just to test new exercises from the handbook, will be removed when all the tips and exercises are in firestore
|
||||||
|
const TrainingExercise: React.FC<ITrainingTip> = (trainingTip: ITrainingTip) => {
|
||||||
|
const leftText = "<div class=\"container mx-auto px-4 overflow-x-auto\"><table class=\"min-w-full bg-white border border-gray-300\"><thead><tr class=\"bg-gray-100\"><th class=\"py-2 px-4 border-b font-semibold text-left\">Category</th><th class=\"py-2 px-4 border-b font-semibold text-left\">Option A</th><th class=\"py-2 px-4 border-b font-semibold text-left\">Option B</th></tr></thead><tbody><tr><td class=\"py-2 px-4 border-b font-medium\">Self</td><td class=\"py-2 px-4 border-b\">You need to take care of yourself and connect with the people around you.</td><td class=\"py-2 px-4 border-b\">Focus on your interests and talents and meet people who are like you.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Home</td><td class=\"py-2 px-4 border-b\">It's a good idea to paint your living room yellow.</td><td class=\"py-2 px-4 border-b\">You should arrange your home so that it makes you feel happy.</td></tr><tr><td class=\"py-2 px-4 border-b font-medium\">Financial Life</td><td class=\"py-2 px-4 border-b\">You can be happy if you have enough money, but don't want money too much.</td><td class=\"py-2 px-4 border-b\">If you waste money on things you don't need, you won't have enough money for things that you do need.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Social Life</td><td class=\"py-2 px-4 border-b\">A good group of friends can increase your happiness.</td><td class=\"py-2 px-4 border-b\">Researchers say that a happy friend can increase our mood by nine percent.</td></tr><tr><td class=\"py-2 px-4 border-b font-medium\">Workplace</td><td class=\"py-2 px-4 border-b\">You spend a lot of time at work, so you should like your workplace.</td><td class=\"py-2 px-4 border-b\">Your boss needs to be someone you enjoy working for.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Community</td><td class=\"py-2 px-4 border-b\">The place where you live is more important for happiness than anything else.</td><td class=\"py-2 px-4 border-b\">Live around people who have the same amount of money as you do.</td></tr></tbody></table></div>";
|
||||||
|
const tip = {
|
||||||
|
category: "Strategy",
|
||||||
|
body: "<p>Look for <b>clues to the main idea</b> in the first (and sometimes second) sentence of a paragraph.</p>"
|
||||||
|
}
|
||||||
|
const question = "<div class=\"container mx-auto px-4 py-8\"><h2 class=\"text-2xl font-bold mb-4\">Identifying Main Ideas</h2><p class=\"text-lg leading-relaxed mb-6\">Read the statements below. Circle the main idea in each pair of statements (a or b).</p></div>";
|
||||||
|
const rightTextData: WalkthroughConfigs[] = [
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-blue-100 p-4 rounded-lg mb-4'><h2 class='text-xl font-bold mb-2'>Identifying Main Ideas</h2><p class='text-gray-700 leading-relaxed'>Let's analyze each pair of statements to determine which one represents the main idea. We'll focus on which statement is more general and encompasses the overall concept.</p></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 5000,
|
||||||
|
"highlight": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-green-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>1. Self</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You need to take care of yourself and connect with the people around you.</b></p><p class='mt-2'>This statement is more comprehensive, covering both self-care and social connections. Option B is more specific and could be considered a subset of A.</p></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 8000,
|
||||||
|
"highlight": ["You need to take care of yourself and connect with the people around you."]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-yellow-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>2. Home</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>B. You should arrange your home so that it makes you feel happy.</b></p><p class='mt-2'>This statement is more general and applies to the entire home. Option A is a specific example that could fall under this broader concept.</p></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 8000,
|
||||||
|
"highlight": ["You should arrange your home so that it makes you feel happy."]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-pink-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>3. Financial Life</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You can be happy if you have enough money, but don't want money too much.</b></p><p class='mt-2'>This statement provides a balanced view of money's role in happiness. Option B is more specific and could be seen as a consequence of wanting money too much.</p></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 8000,
|
||||||
|
"highlight": ["You can be happy if you have enough money, but don't want money too much."]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-purple-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>4. Social Life</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. A good group of friends can increase your happiness.</b></p><p class='mt-2'>This statement is more general about the impact of friendships. Option B provides a specific statistic that supports this main idea.</p></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 8000,
|
||||||
|
"highlight": ["A good group of friends can increase your happiness."]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-indigo-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>5. Workplace</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You spend a lot of time at work, so you should like your workplace.</b></p><p class='mt-2'>This statement covers the overall importance of workplace satisfaction. Option B focuses on one specific aspect (the boss) and is less comprehensive.</p></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 8000,
|
||||||
|
"highlight": ["You spend a lot of time at work, so you should like your workplace."]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-red-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>6. Community</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. The place where you live is more important for happiness than anything else.</b></p><p class='mt-2'>While this statement might be debatable, it's more general and encompasses the overall importance of community. Option B is a specific suggestion about community demographics.</p></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 8000,
|
||||||
|
"highlight": ["The place where you live is more important for happiness than anything else."]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-orange-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>Key Strategy</h3><p class='text-gray-700 leading-relaxed'>When identifying main ideas:</p><ul class='list-disc pl-5 space-y-2'><li>Look for broader, more encompassing statements</li><li>Consider which statement other ideas could fall under</li><li>Identify which statement provides a general principle rather than a specific example</li></ul></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 8000,
|
||||||
|
"highlight": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-teal-50 p-4 rounded-lg'><h3 class='text-lg font-semibold mb-2'>Helpful Tip</h3><p class='text-gray-700 leading-relaxed'>Remember to look for clues to the main idea in the first (and sometimes second) sentence of a paragraph. In this exercise, we applied this concept to pairs of statements. This approach can help you quickly identify the central theme or main point in various types of text.</p></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 5000,
|
||||||
|
"highlight": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockTip: ITrainingTip = {
|
||||||
|
id: "some random id",
|
||||||
|
tipCategory: tip.category,
|
||||||
|
tipHtml: tip.body,
|
||||||
|
standalone: false,
|
||||||
|
exercise: {
|
||||||
|
question: question,
|
||||||
|
highlightable: leftText,
|
||||||
|
segments: rightTextData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col p-10">
|
||||||
|
<ExerciseWalkthrough {...trainingTip}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrainingExercise;
|
||||||
287
src/components/TrainingContent/ExerciseWalkthrough.tsx
Normal file
287
src/components/TrainingContent/ExerciseWalkthrough.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { animated } from '@react-spring/web';
|
||||||
|
import { FaRegCirclePlay, FaRegCircleStop } from "react-icons/fa6";
|
||||||
|
import HighlightedContent from './AnimatedHighlight';
|
||||||
|
import { ITrainingTip, SegmentRef, TimelineEvent } from './TrainingInterfaces';
|
||||||
|
|
||||||
|
|
||||||
|
const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
||||||
|
const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState<number>(0);
|
||||||
|
const [walkthroughHtml, setWalkthroughHtml] = useState<string>('');
|
||||||
|
const [highlightedPhrases, setHighlightedPhrases] = useState<string[]>([]);
|
||||||
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
|
const timelineRef = useRef<TimelineEvent[]>([]);
|
||||||
|
const animationRef = useRef<number | null>(null);
|
||||||
|
const segmentsRef = useRef<SegmentRef[]>([]);
|
||||||
|
|
||||||
|
const toggleAutoPlay = useCallback(() => {
|
||||||
|
setIsAutoPlaying((prev) => {
|
||||||
|
if (!prev && currentTime === getMaxTime()) {
|
||||||
|
setCurrentTime(0);
|
||||||
|
}
|
||||||
|
return !prev;
|
||||||
|
});
|
||||||
|
}, [currentTime]);
|
||||||
|
|
||||||
|
const handleAnimationComplete = useCallback(() => {
|
||||||
|
setIsAutoPlaying(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleResetAnimation = useCallback((newTime: number) => {
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getMaxTime = (): number => {
|
||||||
|
return tip.exercise?.segments.reduce((sum, segment) =>
|
||||||
|
sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0
|
||||||
|
) ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeline: TimelineEvent[] = [];
|
||||||
|
let currentTimePosition = 0;
|
||||||
|
segmentsRef.current = [];
|
||||||
|
|
||||||
|
tip.exercise?.segments.forEach((segment, index) => {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(segment.html, 'text/html');
|
||||||
|
const words: string[] = [];
|
||||||
|
const walkTree = (node: Node) => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
words.push(...(node.textContent?.split(/\s+/).filter(word => word.length > 0) || []));
|
||||||
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
Array.from(node.childNodes).forEach(walkTree);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walkTree(doc.body);
|
||||||
|
|
||||||
|
const textDuration = words.length * segment.wordDelay;
|
||||||
|
|
||||||
|
segmentsRef.current.push({
|
||||||
|
...segment,
|
||||||
|
words: words,
|
||||||
|
startTime: currentTimePosition,
|
||||||
|
endTime: currentTimePosition + textDuration
|
||||||
|
});
|
||||||
|
|
||||||
|
timeline.push({
|
||||||
|
type: 'text',
|
||||||
|
start: currentTimePosition,
|
||||||
|
end: currentTimePosition + textDuration,
|
||||||
|
segmentIndex: index
|
||||||
|
});
|
||||||
|
|
||||||
|
currentTimePosition += textDuration;
|
||||||
|
|
||||||
|
timeline.push({
|
||||||
|
type: 'highlight',
|
||||||
|
start: currentTimePosition,
|
||||||
|
end: currentTimePosition + segment.holdDelay,
|
||||||
|
content: segment.highlight,
|
||||||
|
segmentIndex: index
|
||||||
|
});
|
||||||
|
|
||||||
|
currentTimePosition += segment.holdDelay;
|
||||||
|
});
|
||||||
|
|
||||||
|
timelineRef.current = timeline;
|
||||||
|
}, [tip.exercise?.segments]);
|
||||||
|
|
||||||
|
const updateText = useCallback(() => {
|
||||||
|
const currentEvent = timelineRef.current.find(
|
||||||
|
event => currentTime >= event.start && currentTime < event.end
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentEvent) {
|
||||||
|
if (currentEvent.type === 'text') {
|
||||||
|
const segment = segmentsRef.current[currentEvent.segmentIndex];
|
||||||
|
const elapsedTime = currentTime - currentEvent.start;
|
||||||
|
const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length);
|
||||||
|
|
||||||
|
const previousSegmentsHtml = segmentsRef.current
|
||||||
|
.slice(0, currentEvent.segmentIndex)
|
||||||
|
.map(seg => seg.html)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(segment.html, 'text/html');
|
||||||
|
let wordCount = 0;
|
||||||
|
const walkTree = (node: Node, action: (node: Node) => void): boolean => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE && node.textContent) {
|
||||||
|
const words = node.textContent.split(/(\s+)/).filter(word => word.length > 0);
|
||||||
|
if (wordCount + words.filter(w => !/\s+/.test(w)).length <= wordsToShow) {
|
||||||
|
action(node.cloneNode(true));
|
||||||
|
wordCount += words.filter(w => !/\s+/.test(w)).length;
|
||||||
|
} else {
|
||||||
|
const remainingWords = wordsToShow - wordCount;
|
||||||
|
const newTextContent = words.reduce((acc, word) => {
|
||||||
|
if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) {
|
||||||
|
acc.text += word;
|
||||||
|
acc.nonSpaceWords++;
|
||||||
|
} else if (/\s+/.test(word) || acc.nonSpaceWords < remainingWords) {
|
||||||
|
acc.text += word;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, { text: '', nonSpaceWords: 0 }).text;
|
||||||
|
const newNode = node.cloneNode(false);
|
||||||
|
newNode.textContent = newTextContent;
|
||||||
|
action(newNode);
|
||||||
|
wordCount = wordsToShow;
|
||||||
|
}
|
||||||
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const clone = node.cloneNode(false);
|
||||||
|
action(clone);
|
||||||
|
Array.from(node.childNodes).some(child => {
|
||||||
|
return walkTree(child, childNode => (clone as Node).appendChild(childNode));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return wordCount >= wordsToShow;
|
||||||
|
};
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
walkTree(doc.body, node => fragment.appendChild(node));
|
||||||
|
|
||||||
|
const serializer = new XMLSerializer();
|
||||||
|
const currentSegmentHtml = Array.from(fragment.childNodes)
|
||||||
|
.map(node => serializer.serializeToString(node))
|
||||||
|
.join('');
|
||||||
|
const newHtml = previousSegmentsHtml + currentSegmentHtml;
|
||||||
|
|
||||||
|
setWalkthroughHtml(newHtml);
|
||||||
|
setHighlightedPhrases([]);
|
||||||
|
} else if (currentEvent.type === 'highlight') {
|
||||||
|
const newHtml = segmentsRef.current
|
||||||
|
.slice(0, currentEvent.segmentIndex + 1)
|
||||||
|
.map(seg => seg.html)
|
||||||
|
.join('');
|
||||||
|
setWalkthroughHtml(newHtml);
|
||||||
|
setHighlightedPhrases(currentEvent.content || []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [currentTime]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateText();
|
||||||
|
}, [currentTime, updateText]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAutoPlaying) {
|
||||||
|
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
|
||||||
|
if (lastEvent && currentTime >= lastEvent.end) {
|
||||||
|
setCurrentTime(0);
|
||||||
|
}
|
||||||
|
setIsPlaying(true);
|
||||||
|
} else {
|
||||||
|
setIsPlaying(false);
|
||||||
|
}
|
||||||
|
}, [isAutoPlaying, currentTime]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const animate = () => {
|
||||||
|
if (isPlaying) {
|
||||||
|
setCurrentTime((prevTime) => {
|
||||||
|
const newTime = prevTime + 50;
|
||||||
|
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
|
||||||
|
if (lastEvent && newTime >= lastEvent.end) {
|
||||||
|
setIsPlaying(false);
|
||||||
|
handleAnimationComplete();
|
||||||
|
return lastEvent.end;
|
||||||
|
}
|
||||||
|
return newTime;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationRef.current) {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isPlaying, handleAnimationComplete]);
|
||||||
|
|
||||||
|
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newTime = parseInt(e.target.value, 10);
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
handleResetAnimation(newTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSliderMouseDown = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSliderMouseUp = () => {
|
||||||
|
if (isAutoPlaying) {
|
||||||
|
setIsPlaying(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tip.standalone || !tip.exercise) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<h1 className='text-xl font-bold text-red-600'>The exercise for this tip is not available yet!</h1>
|
||||||
|
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4 mt-10">
|
||||||
|
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
|
||||||
|
<div className="text-gray-700" dangerouslySetInnerHTML={{ __html: tip.tipHtml }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4">
|
||||||
|
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
|
||||||
|
<div className="text-gray-700" dangerouslySetInnerHTML={{ __html: tip.tipHtml }} />
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col space-y-4'>
|
||||||
|
<div className='flex flex-row items-center space-x-4 py-4'>
|
||||||
|
<button
|
||||||
|
onClick={toggleAutoPlay}
|
||||||
|
className="p-2 bg-blue-500 text-white rounded-full transition-colors duration-200 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
|
||||||
|
aria-label={isAutoPlaying ? 'Pause' : 'Play'}
|
||||||
|
>
|
||||||
|
{isAutoPlaying ? (
|
||||||
|
<FaRegCircleStop className="w-6 h-6" />
|
||||||
|
) : (
|
||||||
|
<FaRegCirclePlay className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={timelineRef.current.length > 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0}
|
||||||
|
value={currentTime}
|
||||||
|
onChange={handleSliderChange}
|
||||||
|
onMouseDown={handleSliderMouseDown}
|
||||||
|
onMouseUp={handleSliderMouseUp}
|
||||||
|
onTouchStart={handleSliderMouseDown}
|
||||||
|
onTouchEnd={handleSliderMouseUp}
|
||||||
|
className='flex-grow'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4'>
|
||||||
|
<div className='flex-1 bg-white p-6 rounded-lg shadow'>
|
||||||
|
{/*<h2 className="text-xl font-bold mb-4">Question</h2>*/}
|
||||||
|
<div className="mb-4" dangerouslySetInnerHTML={{ __html: tip.exercise.question }} />
|
||||||
|
<HighlightedContent html={tip.exercise.highlightable} highlightPhrases={highlightedPhrases} />
|
||||||
|
</div>
|
||||||
|
<div className='flex-1'>
|
||||||
|
<div className='bg-gray-50 rounded-lg shadow'>
|
||||||
|
<div className='p-6 space-y-4'>
|
||||||
|
<animated.div
|
||||||
|
dangerouslySetInnerHTML={{ __html: walkthroughHtml }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExerciseWalkthrough;
|
||||||
56
src/components/TrainingContent/TrainingInterfaces.ts
Normal file
56
src/components/TrainingContent/TrainingInterfaces.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Stat } from "@/interfaces/user";
|
||||||
|
|
||||||
|
export interface ITrainingContent {
|
||||||
|
id: string;
|
||||||
|
created_at: number;
|
||||||
|
exams: {
|
||||||
|
id: string;
|
||||||
|
date: number;
|
||||||
|
detailed_summary: string;
|
||||||
|
performance_comment: string;
|
||||||
|
score: number;
|
||||||
|
module: string;
|
||||||
|
stat_ids: string[];
|
||||||
|
stats?: Stat[];
|
||||||
|
}[];
|
||||||
|
tip_ids: string[];
|
||||||
|
tips?: ITrainingTip[];
|
||||||
|
weak_areas: {
|
||||||
|
area: string;
|
||||||
|
comment: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITrainingTip {
|
||||||
|
id: string;
|
||||||
|
tipCategory: string;
|
||||||
|
tipHtml: string;
|
||||||
|
standalone: boolean;
|
||||||
|
exercise?: {
|
||||||
|
question: string;
|
||||||
|
highlightable: string;
|
||||||
|
segments: WalkthroughConfigs[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalkthroughConfigs {
|
||||||
|
html: string;
|
||||||
|
wordDelay: number;
|
||||||
|
holdDelay: number;
|
||||||
|
highlight: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface TimelineEvent {
|
||||||
|
type: 'text' | 'highlight';
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
segmentIndex: number;
|
||||||
|
content?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SegmentRef extends WalkthroughConfigs {
|
||||||
|
words: string[];
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
}
|
||||||
91
src/components/TrainingContent/TrainingScore.tsx
Normal file
91
src/components/TrainingContent/TrainingScore.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RiArrowRightUpLine, RiArrowLeftDownLine } from 'react-icons/ri';
|
||||||
|
import { FaChartLine } from 'react-icons/fa';
|
||||||
|
import { GiLightBulb } from 'react-icons/gi';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { ITrainingContent } from './TrainingInterfaces';
|
||||||
|
|
||||||
|
interface TrainingScoreProps {
|
||||||
|
trainingContent: ITrainingContent
|
||||||
|
gridView: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TrainingScore: React.FC<TrainingScoreProps> = ({
|
||||||
|
trainingContent,
|
||||||
|
gridView
|
||||||
|
}) => {
|
||||||
|
const scores = trainingContent.exams.map(exam => exam.score);
|
||||||
|
const highestScore = Math.max(...scores);
|
||||||
|
const lowestScore = Math.min(...scores);
|
||||||
|
let averageScore = scores.length > 0
|
||||||
|
? scores.reduce((sum, score) => sum + score, 0) / scores.length
|
||||||
|
: 0;
|
||||||
|
averageScore = Math.round(averageScore);
|
||||||
|
|
||||||
|
const containerClasses = clsx(
|
||||||
|
"flex flex-row mb-4",
|
||||||
|
gridView ? "gap-4 justify-between" : "gap-8"
|
||||||
|
);
|
||||||
|
|
||||||
|
const columnClasses = clsx(
|
||||||
|
"flex flex-col",
|
||||||
|
gridView ? "gap-4" : "gap-8"
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClasses}>
|
||||||
|
<div className={columnClasses}>
|
||||||
|
<div className="flex flex-row items-center gap-4">
|
||||||
|
<div className="flex w-14 h-14 bg-[#F5F5F5] items-center justify-center rounded-xl border border-[#DBDBDB]">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M11.7083 3.16669C11.4166 3.16669 11.1701 3.06599 10.9687 2.8646C10.7673 2.66321 10.6666 2.41669 10.6666 2.12502C10.6666 1.83335 10.7673 1.58683 10.9687 1.38544C11.1701 1.18405 11.4166 1.08335 11.7083 1.08335C12 1.08335 12.2465 1.18405 12.4479 1.38544C12.6493 1.58683 12.75 1.83335 12.75 2.12502C12.75 2.41669 12.6493 2.66321 12.4479 2.8646C12.2465 3.06599 12 3.16669 11.7083 3.16669ZM11.7083 16.9167C11.4166 16.9167 11.1701 16.816 10.9687 16.6146C10.7673 16.4132 10.6666 16.1667 10.6666 15.875C10.6666 15.5834 10.7673 15.3368 10.9687 15.1354C11.1701 14.934 11.4166 14.8334 11.7083 14.8334C12 14.8334 12.2465 14.934 12.4479 15.1354C12.6493 15.3368 12.75 15.5834 12.75 15.875C12.75 16.1667 12.6493 16.4132 12.4479 16.6146C12.2465 16.816 12 16.9167 11.7083 16.9167ZM15.0416 6.08335C14.75 6.08335 14.5034 5.98266 14.302 5.78127C14.1007 5.57988 14 5.33335 14 5.04169C14 4.75002 14.1007 4.50349 14.302 4.3021C14.5034 4.10071 14.75 4.00002 15.0416 4.00002C15.3333 4.00002 15.5798 4.10071 15.7812 4.3021C15.9826 4.50349 16.0833 4.75002 16.0833 5.04169C16.0833 5.33335 15.9826 5.57988 15.7812 5.78127C15.5798 5.98266 15.3333 6.08335 15.0416 6.08335ZM15.0416 14C14.75 14 14.5034 13.8993 14.302 13.6979C14.1007 13.4965 14 13.25 14 12.9584C14 12.6667 14.1007 12.4202 14.302 12.2188C14.5034 12.0174 14.75 11.9167 15.0416 11.9167C15.3333 11.9167 15.5798 12.0174 15.7812 12.2188C15.9826 12.4202 16.0833 12.6667 16.0833 12.9584C16.0833 13.25 15.9826 13.4965 15.7812 13.6979C15.5798 13.8993 15.3333 14 15.0416 14ZM16.2916 10.0417C16 10.0417 15.7534 9.94099 15.552 9.7396C15.3507 9.53821 15.25 9.29169 15.25 9.00002C15.25 8.70835 15.3507 8.46183 15.552 8.26044C15.7534 8.05905 16 7.95835 16.2916 7.95835C16.5833 7.95835 16.8298 8.05905 17.0312 8.26044C17.2326 8.46183 17.3333 8.70835 17.3333 9.00002C17.3333 9.29169 17.2326 9.53821 17.0312 9.7396C16.8298 9.94099 16.5833 10.0417 16.2916 10.0417ZM8.99996 17.3334C7.84718 17.3334 6.76385 17.1146 5.74996 16.6771C4.73607 16.2396 3.85413 15.6459 3.10413 14.8959C2.35413 14.1459 1.76038 13.2639 1.32288 12.25C0.885376 11.2361 0.666626 10.1528 0.666626 9.00002C0.666626 7.84724 0.885376 6.76391 1.32288 5.75002C1.76038 4.73613 2.35413 3.85419 3.10413 3.10419C3.85413 2.35419 4.73607 1.76044 5.74996 1.32294C6.76385 0.885437 7.84718 0.666687 8.99996 0.666687V2.33335C7.13885 2.33335 5.56246 2.97919 4.27079 4.27085C2.97913 5.56252 2.33329 7.13891 2.33329 9.00002C2.33329 10.8611 2.97913 12.4375 4.27079 13.7292C5.56246 15.0209 7.13885 15.6667 8.99996 15.6667V17.3334ZM8.99996 10.6667C8.54163 10.6667 8.14927 10.5035 7.82288 10.1771C7.49649 9.85071 7.33329 9.45835 7.33329 9.00002C7.33329 8.93058 7.33676 8.85766 7.34371 8.78127C7.35065 8.70488 7.36801 8.63196 7.39579 8.56252L5.66663 6.83335L6.83329 5.66669L8.56246 7.39585C8.61801 7.38196 8.76385 7.36113 8.99996 7.33335C9.45829 7.33335 9.85065 7.49655 10.177 7.82294C10.5034 8.14933 10.6666 8.54169 10.6666 9.00002C10.6666 9.45835 10.5034 9.85071 10.177 10.1771C9.85065 10.5035 9.45829 10.6667 8.99996 10.6667Z" fill="#40A1EA" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="font-bold">{trainingContent.exams.length}</p>
|
||||||
|
<p>Exams Selected</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-center gap-4">
|
||||||
|
<div className="flex w-14 h-14 bg-[#F5F5F5] items-center justify-center rounded-xl border border-[#DBDBDB]">
|
||||||
|
<RiArrowRightUpLine color={"#22E1B3"} size={gridView ? 28 : 26} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="font-bold">{highestScore}%</p>
|
||||||
|
<p>Highest Score</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={columnClasses}>
|
||||||
|
<div className="flex flex-row items-center gap-4">
|
||||||
|
<div className="flex w-14 h-14 bg-[#F5F5F5] items-center justify-center rounded-xl border border-[#DBDBDB]">
|
||||||
|
<FaChartLine color={"#40A1EA"} size={gridView ? 24 : 26} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="font-bold">{averageScore}%</p>
|
||||||
|
<p>Average Score</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-center gap-4">
|
||||||
|
<div className="flex w-14 h-14 bg-[#F5F5F5] items-center justify-center rounded-xl border border-[#DBDBDB]">
|
||||||
|
<RiArrowLeftDownLine color={"#E13922"} size={gridView ? 28 : 26} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="font-bold">{lowestScore}%</p>
|
||||||
|
<p>Lowest Score</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{gridView && (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 -lg:hidden">
|
||||||
|
<div className="flex w-14 h-14 bg-[#F5F5F5] items-center justify-center rounded-xl border border-[#DBDBDB]">
|
||||||
|
<GiLightBulb color={"#FFCC00"} size={28} />
|
||||||
|
</div>
|
||||||
|
<p><span className="font-bold">{trainingContent.tip_ids.length}</span> Tips</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrainingScore;
|
||||||
File diff suppressed because it is too large
Load Diff
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 />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
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";
|
||||||
|
|||||||
@@ -2,380 +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;
|
<AssignmentView
|
||||||
const pastFilter = (a: Assignment) => (moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length) && !a.archived;
|
isOpen={!!selectedAssignment && !isCreatingAssignment}
|
||||||
const archivedFilter = (a: Assignment) => a.archived;
|
onClose={() => {
|
||||||
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
|
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>
|
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +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 {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;
|
||||||
@@ -25,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: (moduleIndex?: number) => 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);
|
||||||
|
|
||||||
@@ -62,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,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}
|
||||||
@@ -94,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")}
|
||||||
@@ -118,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(
|
||||||
@@ -127,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
|
||||||
@@ -247,6 +296,16 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
|||||||
</button>
|
</button>
|
||||||
<span>Review {capitalize(selectedModule)}</span>
|
<span>Review {capitalize(selectedModule)}</span>
|
||||||
</div>
|
</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,36 +21,84 @@ 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, module: "level", exam: exam.id}]);
|
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);
|
||||||
|
|
||||||
@@ -59,41 +110,111 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
|
scrollToTop();
|
||||||
if (solution) {
|
if (solution) {
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
|
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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ 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 [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
|
||||||
@@ -64,10 +62,6 @@ 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);
|
||||||
@@ -220,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" && (
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ function TextComponent({part, exerciseType}: {part: ReadingPart; exerciseType: s
|
|||||||
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
||||||
{part.text.content
|
{part.text.content
|
||||||
.split(/\n|(\\n)/g)
|
.split(/\n|(\\n)/g)
|
||||||
.filter((x) => x && x.length > 0)
|
.filter((x) => x && x.length > 0 && x !== "\\n")
|
||||||
.map((line, index) => (
|
.map((line, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
{exerciseType === "matchSentences" && (
|
{exerciseType === "matchSentences" && (
|
||||||
@@ -106,8 +106,6 @@ function TextComponent({part, exerciseType}: {part: ReadingPart; exerciseType: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
|
||||||
@@ -155,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);
|
||||||
@@ -314,13 +308,13 @@ export default function Reading({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)}
|
||||||
|
|
||||||
{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
|
||||||
|
|||||||
@@ -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, module: "speaking", exam: exam.id}]);
|
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);
|
||||||
@@ -71,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 (
|
||||||
@@ -81,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}
|
||||||
@@ -89,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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,7 +17,7 @@ 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(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,19 +1,24 @@
|
|||||||
import {Module} from ".";
|
import { Module } from ".";
|
||||||
|
|
||||||
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
||||||
export type Variant = "full" | "partial";
|
export type Variant = "full" | "partial";
|
||||||
export type InstructorGender = "male" | "female" | "varied";
|
export type InstructorGender = "male" | "female" | "varied";
|
||||||
export type Difficulty = "easy" | "medium" | "hard";
|
export type Difficulty = "easy" | "medium" | "hard";
|
||||||
|
|
||||||
export interface ReadingExam {
|
interface ExamBase {
|
||||||
parts: ReadingPart[];
|
|
||||||
id: string;
|
id: string;
|
||||||
module: "reading";
|
module: Module;
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
type: "academic" | "general";
|
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
difficulty?: Difficulty;
|
difficulty?: Difficulty;
|
||||||
|
createdBy?: string; // option as it has been added later
|
||||||
|
createdAt?: string; // option as it has been added later
|
||||||
|
}
|
||||||
|
export interface ReadingExam extends ExamBase {
|
||||||
|
module: "reading";
|
||||||
|
parts: ReadingPart[];
|
||||||
|
type: "academic" | "general";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReadingPart {
|
export interface ReadingPart {
|
||||||
@@ -24,24 +29,19 @@ export interface ReadingPart {
|
|||||||
exercises: Exercise[];
|
exercises: Exercise[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LevelExam {
|
export interface LevelExam extends ExamBase {
|
||||||
module: "level";
|
module: "level";
|
||||||
id: string;
|
parts: LevelPart[];
|
||||||
exercises: Exercise[];
|
|
||||||
minTimer: number;
|
|
||||||
isDiagnostic: boolean;
|
|
||||||
variant?: Variant;
|
|
||||||
difficulty?: Difficulty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListeningExam {
|
export interface LevelPart {
|
||||||
|
context?: string;
|
||||||
|
exercises: Exercise[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListeningExam extends ExamBase {
|
||||||
parts: ListeningPart[];
|
parts: ListeningPart[];
|
||||||
id: string;
|
|
||||||
module: "listening";
|
module: "listening";
|
||||||
minTimer: number;
|
|
||||||
isDiagnostic: boolean;
|
|
||||||
variant?: Variant;
|
|
||||||
difficulty?: Difficulty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListeningPart {
|
export interface ListeningPart {
|
||||||
@@ -67,14 +67,9 @@ export interface UserSolution {
|
|||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WritingExam {
|
export interface WritingExam extends ExamBase {
|
||||||
module: "writing";
|
module: "writing";
|
||||||
id: string;
|
|
||||||
exercises: WritingExercise[];
|
exercises: WritingExercise[];
|
||||||
minTimer: number;
|
|
||||||
isDiagnostic: boolean;
|
|
||||||
variant?: Variant;
|
|
||||||
difficulty?: Difficulty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WordCounter {
|
interface WordCounter {
|
||||||
@@ -82,15 +77,10 @@ interface WordCounter {
|
|||||||
limit: number;
|
limit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpeakingExam {
|
export interface SpeakingExam extends ExamBase {
|
||||||
id: string;
|
|
||||||
module: "speaking";
|
module: "speaking";
|
||||||
exercises: (SpeakingExercise | InteractiveSpeakingExercise)[];
|
exercises: (SpeakingExercise | InteractiveSpeakingExercise)[];
|
||||||
minTimer: number;
|
|
||||||
isDiagnostic: boolean;
|
|
||||||
variant?: Variant;
|
|
||||||
instructorGender: InstructorGender;
|
instructorGender: InstructorGender;
|
||||||
difficulty?: Difficulty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Exercise =
|
export type Exercise =
|
||||||
@@ -106,21 +96,25 @@ 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 {
|
|
||||||
perfect_answer_1?: string;
|
type InteractivePerfectAnswerKey = `perfect_answer_${number}`;
|
||||||
transcript_1?: string;
|
type InteractiveTranscriptKey = `transcript_${number}`;
|
||||||
fixed_text_1?: string;
|
type InteractiveFixedTextKey = `fixed_text_${number}`;
|
||||||
perfect_answer_2?: string;
|
|
||||||
transcript_2?: string;
|
type InteractivePerfectAnswerType = { [key in InteractivePerfectAnswerKey]: { answer: string } };
|
||||||
fixed_text_2?: string;
|
type InteractiveTranscriptType = { [key in InteractiveTranscriptKey]?: string };
|
||||||
perfect_answer_3?: string;
|
type InteractiveFixedTextType = { [key in InteractiveFixedTextKey]?: string };
|
||||||
transcript_3?: string;
|
|
||||||
fixed_text_3?: string;
|
interface InteractiveSpeakingEvaluation extends Evaluation,
|
||||||
}
|
InteractivePerfectAnswerType,
|
||||||
|
InteractiveTranscriptType,
|
||||||
|
InteractiveFixedTextType
|
||||||
|
{}
|
||||||
|
|
||||||
|
|
||||||
interface SpeakingEvaluation extends CommonEvaluation {
|
interface SpeakingEvaluation extends CommonEvaluation {
|
||||||
perfect_answer_1?: string;
|
perfect_answer_1?: string;
|
||||||
@@ -147,17 +141,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;
|
||||||
@@ -171,6 +184,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: {
|
||||||
@@ -179,13 +194,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: {
|
||||||
|
|||||||
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,5 +1,6 @@
|
|||||||
import { Module } from ".";
|
import { Module } from ".";
|
||||||
import { InstructorGender } from "./exam";
|
import { InstructorGender } from "./exam";
|
||||||
|
import { PermissionType } from "./permissions";
|
||||||
|
|
||||||
export type User =
|
export type User =
|
||||||
| StudentUser
|
| StudentUser
|
||||||
@@ -7,7 +8,8 @@ export type User =
|
|||||||
| CorporateUser
|
| CorporateUser
|
||||||
| AgentUser
|
| AgentUser
|
||||||
| AdminUser
|
| AdminUser
|
||||||
| DeveloperUser;
|
| DeveloperUser
|
||||||
|
| MasterCorporateUser;
|
||||||
export type UserStatus = "active" | "disabled" | "paymentDue";
|
export type UserStatus = "active" | "disabled" | "paymentDue";
|
||||||
|
|
||||||
export interface BasicUser {
|
export interface BasicUser {
|
||||||
@@ -25,6 +27,7 @@ export interface BasicUser {
|
|||||||
subscriptionExpirationDate?: null | Date;
|
subscriptionExpirationDate?: null | Date;
|
||||||
registrationDate?: Date;
|
registrationDate?: Date;
|
||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
|
permissions: PermissionType[],
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StudentUser extends BasicUser {
|
export interface StudentUser extends BasicUser {
|
||||||
@@ -45,6 +48,12 @@ export interface CorporateUser extends BasicUser {
|
|||||||
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;
|
||||||
@@ -131,6 +140,7 @@ export interface Stat {
|
|||||||
solutions: any[];
|
solutions: any[];
|
||||||
type: string;
|
type: string;
|
||||||
timeSpent?: number;
|
timeSpent?: number;
|
||||||
|
inactivity?: number;
|
||||||
assignment?: string;
|
assignment?: string;
|
||||||
score: {
|
score: {
|
||||||
correct: number;
|
correct: number;
|
||||||
@@ -166,7 +176,8 @@ export type Type =
|
|||||||
| "corporate"
|
| "corporate"
|
||||||
| "admin"
|
| "admin"
|
||||||
| "developer"
|
| "developer"
|
||||||
| "agent";
|
| "agent"
|
||||||
|
| "mastercorporate";
|
||||||
export const userTypes: Type[] = [
|
export const userTypes: Type[] = [
|
||||||
"student",
|
"student",
|
||||||
"teacher",
|
"teacher",
|
||||||
@@ -174,4 +185,5 @@ export const userTypes: Type[] = [
|
|||||||
"admin",
|
"admin",
|
||||||
"developer",
|
"developer",
|
||||||
"agent",
|
"agent",
|
||||||
|
"mastercorporate",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,250 +1,366 @@
|
|||||||
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 useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
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, uniqBy} from "lodash";
|
import { capitalize, uniqBy } 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 {useFilePicker} from "use-file-picker";
|
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(
|
||||||
|
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/
|
||||||
|
);
|
||||||
|
|
||||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
const USER_TYPE_PERMISSIONS: {
|
||||||
|
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
||||||
const USER_TYPE_PERMISSIONS: {[key in Type]: Type[]} = {
|
} = {
|
||||||
student: [],
|
student: {
|
||||||
teacher: [],
|
perm: "createCodeStudent",
|
||||||
agent: [],
|
list: [],
|
||||||
corporate: ["student", "teacher"],
|
},
|
||||||
admin: ["student", "teacher", "agent", "corporate", "admin"],
|
teacher: {
|
||||||
developer: ["student", "teacher", "agent", "corporate", "admin", "developer"],
|
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 }) {
|
||||||
const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]);
|
const [infos, setInfos] = useState<
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
{ email: string; name: string; passport_id: string }[]
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
>([]);
|
||||||
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
);
|
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
user?.subscriptionExpirationDate
|
||||||
const [type, setType] = useState<Type>("student");
|
? moment(user.subscriptionExpirationDate).toDate()
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
: null
|
||||||
|
);
|
||||||
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
|
const [type, setType] = useState<Type>("student");
|
||||||
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
|
||||||
const {users} = useUsers();
|
const { users } = useUsers();
|
||||||
|
|
||||||
const {openFilePicker, filesContent, clear} = useFilePicker({
|
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||||
accept: ".xlsx",
|
accept: ".xlsx",
|
||||||
multiple: false,
|
multiple: false,
|
||||||
readAs: "ArrayBuffer",
|
readAs: "ArrayBuffer",
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => console.log(expiryDate), [expiryDate]);
|
useEffect(() => {
|
||||||
|
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||||
|
}, [isExpiryDateEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
if (filesContent.length > 0) {
|
||||||
}, [isExpiryDateEnabled]);
|
const file = filesContent[0];
|
||||||
|
readXlsxFile(file.content).then((rows) => {
|
||||||
|
try {
|
||||||
|
const information = uniqBy(
|
||||||
|
rows
|
||||||
|
.map((row) => {
|
||||||
|
const [
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
country,
|
||||||
|
passport_id,
|
||||||
|
email,
|
||||||
|
...phone
|
||||||
|
] = row as string[];
|
||||||
|
return EMAIL_REGEX.test(email.toString().trim())
|
||||||
|
? {
|
||||||
|
email: email.toString().trim().toLowerCase(),
|
||||||
|
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
||||||
|
passport_id: passport_id?.toString().trim() || undefined,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
})
|
||||||
|
.filter((x) => !!x) as typeof infos,
|
||||||
|
(x) => x.email
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
if (information.length === 0) {
|
||||||
if (filesContent.length > 0) {
|
toast.error(
|
||||||
const file = filesContent[0];
|
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!"
|
||||||
readXlsxFile(file.content).then((rows) => {
|
);
|
||||||
try {
|
return clear();
|
||||||
const information = uniqBy(
|
}
|
||||||
rows
|
|
||||||
.map((row) => {
|
|
||||||
const [firstName, lastName, country, passport_id, email, ...phone] = row as string[];
|
|
||||||
return EMAIL_REGEX.test(email.toString().trim())
|
|
||||||
? {
|
|
||||||
email: email.toString().trim().toLowerCase(),
|
|
||||||
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
|
||||||
passport_id: passport_id?.toString().trim() || undefined,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
})
|
|
||||||
.filter((x) => !!x) as typeof infos,
|
|
||||||
(x) => x.email,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (information.length === 0) {
|
setInfos(information);
|
||||||
toast.error(
|
} catch {
|
||||||
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
|
toast.error(
|
||||||
);
|
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!"
|
||||||
return clear();
|
);
|
||||||
}
|
return clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [filesContent]);
|
||||||
|
|
||||||
setInfos(information);
|
const generateAndInvite = async () => {
|
||||||
} catch {
|
const newUsers = infos.filter(
|
||||||
toast.error(
|
(x) => !users.map((u) => u.email).includes(x.email)
|
||||||
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
|
);
|
||||||
);
|
const existingUsers = infos
|
||||||
return clear();
|
.filter((x) => users.map((u) => u.email).includes(x.email))
|
||||||
}
|
.map((i) => users.find((u) => u.email === i.email))
|
||||||
});
|
.filter((x) => !!x && x.type === "student") as User[];
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [filesContent]);
|
|
||||||
|
|
||||||
const generateAndInvite = async () => {
|
const newUsersSentence =
|
||||||
const newUsers = infos.filter((x) => !users.map((u) => u.email).includes(x.email));
|
newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
|
||||||
const existingUsers = infos
|
const existingUsersSentence =
|
||||||
.filter((x) => users.map((u) => u.email).includes(x.email))
|
existingUsers.length > 0
|
||||||
.map((i) => users.find((u) => u.email === i.email))
|
? `invite ${existingUsers.length} registered student(s)`
|
||||||
.filter((x) => !!x && x.type === "student") as User[];
|
: undefined;
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
`You are about to ${[newUsersSentence, existingUsersSentence]
|
||||||
|
.filter((x) => !!x)
|
||||||
|
.join(" and ")}, are you sure you want to continue?`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
const newUsersSentence = newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
|
setIsLoading(true);
|
||||||
const existingUsersSentence = existingUsers.length > 0 ? `invite ${existingUsers.length} registered student(s)` : undefined;
|
Promise.all(
|
||||||
if (
|
existingUsers.map(
|
||||||
!confirm(
|
async (u) =>
|
||||||
`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`,
|
await axios.post(`/api/invites`, { to: u.id, from: user.id })
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
.then(() =>
|
||||||
|
toast.success(
|
||||||
|
`Successfully invited ${existingUsers.length} registered student(s)!`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.finally(() => {
|
||||||
|
if (newUsers.length === 0) setIsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
setIsLoading(true);
|
if (newUsers.length > 0) generateCode(type, newUsers);
|
||||||
Promise.all(existingUsers.map(async (u) => await axios.post(`/api/invites`, {to: u.id, from: user.id})))
|
setInfos([]);
|
||||||
.then(() => toast.success(`Successfully invited ${existingUsers.length} registered student(s)!`))
|
};
|
||||||
.finally(() => {
|
|
||||||
if (newUsers.length === 0) setIsLoading(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (newUsers.length > 0) generateCode(type, newUsers);
|
const generateCode = (type: Type, informations: typeof infos) => {
|
||||||
setInfos([]);
|
const uid = new ShortUniqueId();
|
||||||
};
|
const codes = informations.map(() => uid.randomUUID(6));
|
||||||
|
|
||||||
const generateCode = (type: Type, informations: typeof infos) => {
|
setIsLoading(true);
|
||||||
const uid = new ShortUniqueId();
|
axios
|
||||||
const codes = informations.map(() => uid.randomUUID(6));
|
.post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", {
|
||||||
|
type,
|
||||||
|
codes,
|
||||||
|
infos: informations,
|
||||||
|
expiryDate,
|
||||||
|
})
|
||||||
|
.then(({ data, status }) => {
|
||||||
|
if (data.ok) {
|
||||||
|
toast.success(
|
||||||
|
`Successfully generated${
|
||||||
|
data.valid ? ` ${data.valid}/${informations.length}` : ""
|
||||||
|
} ${capitalize(type)} codes and they have been notified by e-mail!`,
|
||||||
|
{ toastId: "success" }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
if (status === 403) {
|
||||||
axios
|
toast.error(data.reason, { toastId: "forbidden" });
|
||||||
.post<{ok: boolean; valid?: number; reason?: string}>("/api/code", {
|
}
|
||||||
type,
|
})
|
||||||
codes,
|
.catch(({ response: { status, data } }) => {
|
||||||
infos: informations,
|
if (status === 403) {
|
||||||
expiryDate,
|
toast.error(data.reason, { toastId: "forbidden" });
|
||||||
})
|
return;
|
||||||
.then(({data, status}) => {
|
}
|
||||||
if (data.ok) {
|
|
||||||
toast.success(
|
|
||||||
`Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize(
|
|
||||||
type,
|
|
||||||
)} codes and they have been notified by e-mail!`,
|
|
||||||
{toastId: "success"},
|
|
||||||
);
|
|
||||||
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}}) => {
|
.finally(() => {
|
||||||
if (status === 403) {
|
setIsLoading(false);
|
||||||
toast.error(data.reason, {toastId: "forbidden"});
|
return clear();
|
||||||
return;
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
toast.error(`Something went wrong, please try again later!`, {
|
return (
|
||||||
toastId: "error",
|
<>
|
||||||
});
|
<Modal
|
||||||
})
|
isOpen={showHelp}
|
||||||
.finally(() => {
|
onClose={() => setShowHelp(false)}
|
||||||
setIsLoading(false);
|
title="Excel File Format"
|
||||||
return clear();
|
>
|
||||||
});
|
<div className="mt-4 flex flex-col gap-2">
|
||||||
};
|
<span>Please upload an Excel file with the following format:</span>
|
||||||
|
<table className="w-full">
|
||||||
return (
|
<thead>
|
||||||
<>
|
<tr>
|
||||||
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)} title="Excel File Format">
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
<div className="mt-4 flex flex-col gap-2">
|
First Name
|
||||||
<span>Please upload an Excel file with the following format:</span>
|
</th>
|
||||||
<table className="w-full">
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
<thead>
|
Last Name
|
||||||
<tr>
|
</th>
|
||||||
<th className="border border-neutral-200 px-2 py-1">First Name</th>
|
<th className="border border-neutral-200 px-2 py-1">Country</th>
|
||||||
<th className="border border-neutral-200 px-2 py-1">Last Name</th>
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
<th className="border border-neutral-200 px-2 py-1">Country</th>
|
Passport/National ID
|
||||||
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
|
</th>
|
||||||
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
|
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
|
||||||
<th className="border border-neutral-200 px-2 py-1">Phone Number</th>
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
</tr>
|
Phone Number
|
||||||
</thead>
|
</th>
|
||||||
</table>
|
</tr>
|
||||||
<span className="mt-4">
|
</thead>
|
||||||
<b>Notes:</b>
|
</table>
|
||||||
<ul>
|
<span className="mt-4">
|
||||||
<li>- All incorrect e-mails will be ignored;</li>
|
<b>Notes:</b>
|
||||||
<li>- All already registered e-mails will be ignored;</li>
|
<ul>
|
||||||
<li>- You may have a header row with the format above, however, it is not necessary;</li>
|
<li>- All incorrect e-mails will be ignored;</li>
|
||||||
<li>- All of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.</li>
|
<li>- All already registered e-mails will be ignored;</li>
|
||||||
</ul>
|
<li>
|
||||||
</span>
|
- You may have a header row with the format above, however, it
|
||||||
</div>
|
is not necessary;
|
||||||
</Modal>
|
</li>
|
||||||
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
|
<li>
|
||||||
<div className="flex items-end justify-between">
|
- All of the e-mails in the file will receive an e-mail to join
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label>
|
EnCoach with the role selected below.
|
||||||
<div className="tooltip cursor-pointer" data-tip="Excel File Format" onClick={() => setShowHelp(true)}>
|
</li>
|
||||||
<BsQuestionCircleFill />
|
</ul>
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
|
</Modal>
|
||||||
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
|
||||||
</Button>
|
<div className="flex items-end justify-between">
|
||||||
{user && (user.type === "developer" || user.type === "admin" || user.type === "corporate") && (
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
<>
|
Choose an Excel file
|
||||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
</label>
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
<div
|
||||||
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
|
className="tooltip cursor-pointer"
|
||||||
Enabled
|
data-tip="Excel File Format"
|
||||||
</Checkbox>
|
onClick={() => setShowHelp(true)}
|
||||||
</div>
|
>
|
||||||
{isExpiryDateEnabled && (
|
<BsQuestionCircleFill />
|
||||||
<ReactDatePicker
|
</div>
|
||||||
className={clsx(
|
</div>
|
||||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
<Button
|
||||||
"hover:border-mti-purple tooltip",
|
onClick={openFilePicker}
|
||||||
"transition duration-300 ease-in-out",
|
isLoading={isLoading}
|
||||||
)}
|
disabled={isLoading}
|
||||||
filterDate={(date) =>
|
>
|
||||||
moment(date).isAfter(new Date()) &&
|
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||||
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
|
</Button>
|
||||||
}
|
{user &&
|
||||||
dateFormat="dd/MM/yyyy"
|
checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
||||||
selected={expiryDate}
|
<>
|
||||||
onChange={(date) => setExpiryDate(date)}
|
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||||
/>
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
)}
|
Expiry Date
|
||||||
</>
|
</label>
|
||||||
)}
|
<Checkbox
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Select the type of user they should be</label>
|
isChecked={isExpiryDateEnabled}
|
||||||
{user && (
|
onChange={setIsExpiryDateEnabled}
|
||||||
<select
|
disabled={!!user.subscriptionExpirationDate}
|
||||||
defaultValue="student"
|
>
|
||||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
Enabled
|
||||||
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">
|
</Checkbox>
|
||||||
{Object.keys(USER_TYPE_LABELS)
|
</div>
|
||||||
.filter((x) => USER_TYPE_PERMISSIONS[user.type].includes(x as Type))
|
{isExpiryDateEnabled && (
|
||||||
.map((type) => (
|
<ReactDatePicker
|
||||||
<option key={type} value={type}>
|
className={clsx(
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||||
</option>
|
"hover:border-mti-purple tooltip",
|
||||||
))}
|
"transition duration-300 ease-in-out"
|
||||||
</select>
|
)}
|
||||||
)}
|
filterDate={(date) =>
|
||||||
<Button onClick={generateAndInvite} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
|
moment(date).isAfter(new Date()) &&
|
||||||
Generate & Send
|
(user.subscriptionExpirationDate
|
||||||
</Button>
|
? moment(date).isBefore(user.subscriptionExpirationDate)
|
||||||
</div>
|
: true)
|
||||||
</>
|
}
|
||||||
);
|
dateFormat="dd/MM/yyyy"
|
||||||
|
selected={expiryDate}
|
||||||
|
onChange={(date) => setExpiryDate(date)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
|
Select the type of user they should be
|
||||||
|
</label>
|
||||||
|
{user && (
|
||||||
|
<select
|
||||||
|
defaultValue="student"
|
||||||
|
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||||
|
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"
|
||||||
|
>
|
||||||
|
{Object.keys(USER_TYPE_LABELS)
|
||||||
|
.filter((x) => {
|
||||||
|
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||||
|
return checkAccess(user, list, perm);
|
||||||
|
})
|
||||||
|
.map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={generateAndInvite}
|
||||||
|
disabled={
|
||||||
|
infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Generate & Send
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
243
src/pages/(admin)/BatchCreateUser.tsx
Normal file
243
src/pages/(admin)/BatchCreateUser.tsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import { Type as UserType, User } from "@/interfaces/user";
|
||||||
|
import axios from "axios";
|
||||||
|
import { uniqBy } from "lodash";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { useFilePicker } from "use-file-picker";
|
||||||
|
import readXlsxFile from "read-excel-file";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import { BsQuestionCircleFill } from "react-icons/bs";
|
||||||
|
import { PermissionType } from "@/interfaces/permissions";
|
||||||
|
const EMAIL_REGEX = new RegExp(
|
||||||
|
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/
|
||||||
|
);
|
||||||
|
|
||||||
|
type Type = Exclude<UserType, "admin" | "developer" | "agent" | "mastercorporate">
|
||||||
|
|
||||||
|
const USER_TYPE_LABELS: {[key in Type]: string} = {
|
||||||
|
student: "Student",
|
||||||
|
teacher: "Teacher",
|
||||||
|
corporate: "Corporate",
|
||||||
|
};
|
||||||
|
|
||||||
|
const USER_TYPE_PERMISSIONS: {
|
||||||
|
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
||||||
|
} = {
|
||||||
|
student: {
|
||||||
|
perm: "createCodeStudent",
|
||||||
|
list: [],
|
||||||
|
},
|
||||||
|
teacher: {
|
||||||
|
perm: "createCodeTeacher",
|
||||||
|
list: [],
|
||||||
|
},
|
||||||
|
corporate: {
|
||||||
|
perm: "createCodeCorporate",
|
||||||
|
list: ["student", "teacher"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BatchCreateUser({ user }: { user: User }) {
|
||||||
|
const [infos, setInfos] = useState<
|
||||||
|
{ email: string; name: string; passport_id:string, type: Type, demographicInformation: {
|
||||||
|
country: string,
|
||||||
|
passport_id:string,
|
||||||
|
phone: string
|
||||||
|
} }[]
|
||||||
|
>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [type, setType] = useState<Type>("student");
|
||||||
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
|
||||||
|
const { users } = useUsers();
|
||||||
|
|
||||||
|
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||||
|
accept: ".xlsx",
|
||||||
|
multiple: false,
|
||||||
|
readAs: "ArrayBuffer",
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (filesContent.length > 0) {
|
||||||
|
const file = filesContent[0];
|
||||||
|
readXlsxFile(file.content).then((rows) => {
|
||||||
|
try {
|
||||||
|
const information = uniqBy(
|
||||||
|
rows
|
||||||
|
.map((row) => {
|
||||||
|
const [
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
country,
|
||||||
|
passport_id,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
group
|
||||||
|
] = row as string[];
|
||||||
|
return EMAIL_REGEX.test(email.toString().trim())
|
||||||
|
? {
|
||||||
|
email: email.toString().trim().toLowerCase(),
|
||||||
|
name: `${firstName ?? ""} ${lastName ?? ""}`.trim().toLowerCase(),
|
||||||
|
type: type,
|
||||||
|
passport_id: passport_id?.toString().trim() || undefined,
|
||||||
|
groupName: group,
|
||||||
|
demographicInformation: {
|
||||||
|
country: country,
|
||||||
|
passport_id: passport_id?.toString().trim() || undefined,
|
||||||
|
phone,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
})
|
||||||
|
.filter((x) => !!x) as typeof infos,
|
||||||
|
(x) => x.email
|
||||||
|
);
|
||||||
|
|
||||||
|
if (information.length === 0) {
|
||||||
|
toast.error(
|
||||||
|
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!"
|
||||||
|
);
|
||||||
|
return clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
setInfos(information);
|
||||||
|
} catch {
|
||||||
|
toast.error(
|
||||||
|
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!"
|
||||||
|
);
|
||||||
|
return clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [filesContent]);
|
||||||
|
|
||||||
|
const makeUsers = async () => {
|
||||||
|
const newUsers = infos.filter(
|
||||||
|
(x) => !users.map((u) => u.email).includes(x.email)
|
||||||
|
);
|
||||||
|
const confirmed = confirm(
|
||||||
|
`You are about to add ${newUsers.length}, are you sure you want to continue?`
|
||||||
|
)
|
||||||
|
if (!confirmed)
|
||||||
|
return;
|
||||||
|
if (newUsers.length > 0)
|
||||||
|
{
|
||||||
|
setIsLoading(true);
|
||||||
|
Promise.all(newUsers.map(async (user) => {
|
||||||
|
await axios.post("/api/make_user", user)
|
||||||
|
})).then((res) =>{
|
||||||
|
toast.success(
|
||||||
|
`Successfully added ${newUsers.length} user(s)!`
|
||||||
|
)}).finally(() => {
|
||||||
|
return clear();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
setInfos([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
isOpen={showHelp}
|
||||||
|
onClose={() => setShowHelp(false)}
|
||||||
|
title="Excel File Format"
|
||||||
|
>
|
||||||
|
<div className="mt-4 flex flex-col gap-2">
|
||||||
|
<span>Please upload an Excel file with the following format:</span>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
|
First Name
|
||||||
|
</th>
|
||||||
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
|
Last Name
|
||||||
|
</th>
|
||||||
|
<th className="border border-neutral-200 px-2 py-1">Country</th>
|
||||||
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
|
Passport/National ID
|
||||||
|
</th>
|
||||||
|
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
|
||||||
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
|
Phone Number
|
||||||
|
</th>
|
||||||
|
<th className="border border-neutral-200 px-2 py-1">
|
||||||
|
Group Name
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
<span className="mt-4">
|
||||||
|
<b>Notes:</b>
|
||||||
|
<ul>
|
||||||
|
<li>- All incorrect e-mails will be ignored;</li>
|
||||||
|
<li>- All already registered e-mails will be ignored;</li>
|
||||||
|
<li>
|
||||||
|
- You may have a header row with the format above, however, it
|
||||||
|
is not necessary;
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
- All of the e-mails in the file will receive an e-mail to join
|
||||||
|
EnCoach with the role selected below.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
|
Choose an Excel file
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="tooltip cursor-pointer"
|
||||||
|
data-tip="Excel File Format"
|
||||||
|
onClick={() => setShowHelp(true)}
|
||||||
|
>
|
||||||
|
<BsQuestionCircleFill />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={openFilePicker}
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||||
|
</Button>
|
||||||
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
|
Select the type of user they should be
|
||||||
|
</label>
|
||||||
|
{user && (
|
||||||
|
<select
|
||||||
|
defaultValue="student"
|
||||||
|
onChange={(e) => setType(e.target.value as Type)}
|
||||||
|
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"
|
||||||
|
>
|
||||||
|
|
||||||
|
{Object.keys(USER_TYPE_LABELS)
|
||||||
|
.map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
className="my-auto"
|
||||||
|
onClick={makeUsers}
|
||||||
|
disabled={
|
||||||
|
infos.length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,125 +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>(
|
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||||
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
|
user?.subscriptionExpirationDate
|
||||||
);
|
? moment(user.subscriptionExpirationDate).toDate()
|
||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
: null
|
||||||
const [type, setType] = useState<Type>("student");
|
);
|
||||||
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
|
const [type, setType] = useState<Type>("student");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||||
}, [isExpiryDateEnabled]);
|
}, [isExpiryDateEnabled]);
|
||||||
|
|
||||||
const generateCode = (type: Type) => {
|
const generateCode = (type: Type) => {
|
||||||
const uid = new ShortUniqueId();
|
const uid = new ShortUniqueId();
|
||||||
const code = uid.randomUUID(6);
|
const code = uid.randomUUID(6);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post("/api/code", {type, codes: [code], expiryDate})
|
.post("/api/code", { type, codes: [code], expiryDate })
|
||||||
.then(({data, status}) => {
|
.then(({ data, status }) => {
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
toast.success(`Successfully generated a ${capitalize(type)} code!`, {toastId: "success"});
|
toast.success(`Successfully generated a ${capitalize(type)} code!`, {
|
||||||
setGeneratedCode(code);
|
toastId: "success",
|
||||||
return;
|
});
|
||||||
}
|
setGeneratedCode(code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (status === 403) {
|
if (status === 403) {
|
||||||
toast.error(data.reason, {toastId: "forbidden"});
|
toast.error(data.reason, { toastId: "forbidden" });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(({response: {status, data}}) => {
|
.catch(({ response: { status, data } }) => {
|
||||||
if (status === 403) {
|
if (status === 403) {
|
||||||
toast.error(data.reason, {toastId: "forbidden"});
|
toast.error(data.reason, { toastId: "forbidden" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error(`Something went wrong, please try again later!`, {toastId: "error"});
|
toast.error(`Something went wrong, please try again later!`, {
|
||||||
});
|
toastId: "error",
|
||||||
};
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
<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</label>
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
{user && (
|
User Code Generator
|
||||||
<select
|
</label>
|
||||||
defaultValue="student"
|
{user && (
|
||||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
<select
|
||||||
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">
|
defaultValue="student"
|
||||||
{Object.keys(USER_TYPE_LABELS)
|
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||||
.filter((x) => USER_TYPE_PERMISSIONS[user.type].includes(x as Type))
|
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"
|
||||||
.map((type) => (
|
>
|
||||||
<option key={type} value={type}>
|
{Object.keys(USER_TYPE_LABELS)
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
.filter((x) => {
|
||||||
</option>
|
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||||
))}
|
return checkAccess(user, list, perm);
|
||||||
</select>
|
})
|
||||||
)}
|
.map((type) => (
|
||||||
{user && (user.type === "developer" || user.type === "admin" || user.type === "corporate") && (
|
<option key={type} value={type}>
|
||||||
<>
|
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
</option>
|
||||||
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
))}
|
||||||
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
|
</select>
|
||||||
Enabled
|
)}
|
||||||
</Checkbox>
|
{user &&
|
||||||
</div>
|
checkAccess(user, ["developer", "admin", "corporate"]) && (
|
||||||
{isExpiryDateEnabled && (
|
<>
|
||||||
<ReactDatePicker
|
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||||
className={clsx(
|
<label className="text-mti-gray-dim text-base font-normal">
|
||||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
Expiry Date
|
||||||
"hover:border-mti-purple tooltip",
|
</label>
|
||||||
"transition duration-300 ease-in-out",
|
<Checkbox
|
||||||
)}
|
isChecked={isExpiryDateEnabled}
|
||||||
filterDate={(date) =>
|
onChange={setIsExpiryDateEnabled}
|
||||||
moment(date).isAfter(new Date()) &&
|
disabled={!!user.subscriptionExpirationDate}
|
||||||
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
|
>
|
||||||
}
|
Enabled
|
||||||
dateFormat="dd/MM/yyyy"
|
</Checkbox>
|
||||||
selected={expiryDate}
|
</div>
|
||||||
onChange={(date) => setExpiryDate(date)}
|
{isExpiryDateEnabled && (
|
||||||
/>
|
<ReactDatePicker
|
||||||
)}
|
className={clsx(
|
||||||
</>
|
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||||
)}
|
"hover:border-mti-purple tooltip",
|
||||||
<Button onClick={() => generateCode(type)} disabled={isExpiryDateEnabled ? !expiryDate : false}>
|
"transition duration-300 ease-in-out"
|
||||||
Generate
|
)}
|
||||||
</Button>
|
filterDate={(date) =>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Generated Code:</label>
|
moment(date).isAfter(new Date()) &&
|
||||||
<div
|
(user.subscriptionExpirationDate
|
||||||
className={clsx(
|
? moment(date).isBefore(user.subscriptionExpirationDate)
|
||||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
: true)
|
||||||
"hover:border-mti-purple tooltip",
|
}
|
||||||
"transition duration-300 ease-in-out",
|
dateFormat="dd/MM/yyyy"
|
||||||
)}
|
selected={expiryDate}
|
||||||
data-tip="Click to copy"
|
onChange={(date) => setExpiryDate(date)}
|
||||||
onClick={() => {
|
/>
|
||||||
if (generatedCode) navigator.clipboard.writeText(generatedCode);
|
)}
|
||||||
}}>
|
</>
|
||||||
{generatedCode}
|
)}
|
||||||
</div>
|
<Button
|
||||||
{generatedCode && <span className="text-sm text-mti-gray-dim font-light">Give this code to the user to complete their registration</span>}
|
onClick={() => generateCode(type)}
|
||||||
</div>
|
disabled={isExpiryDateEnabled ? !expiryDate : false}
|
||||||
);
|
>
|
||||||
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import {
|
|||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import { BsTrash } from "react-icons/bs";
|
import { BsTrash } from "react-icons/bs";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
import ReactDatePicker from "react-datepicker";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<Code>();
|
const columnHelper = createColumnHelper<Code>();
|
||||||
|
|
||||||
@@ -41,41 +43,51 @@ export default function CodeList({ user }: { user: User }) {
|
|||||||
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
|
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
|
||||||
|
|
||||||
const [filteredCorporate, setFilteredCorporate] = useState<User | undefined>(
|
const [filteredCorporate, setFilteredCorporate] = useState<User | undefined>(
|
||||||
user?.type === "corporate" ? user : undefined,
|
user?.type === "corporate" ? user : undefined
|
||||||
);
|
);
|
||||||
const [filterAvailability, setFilterAvailability] = useState<
|
const [filterAvailability, setFilterAvailability] = useState<
|
||||||
"in-use" | "unused"
|
"in-use" | "unused"
|
||||||
>();
|
>();
|
||||||
|
|
||||||
const [filteredCodes, setFilteredCodes] = useState<Code[]>([]);
|
// const [filteredCodes, setFilteredCodes] = useState<Code[]>([]);
|
||||||
|
|
||||||
const { users } = useUsers();
|
const { users } = useUsers();
|
||||||
const { codes, reload } = useCodes(
|
const { codes, reload } = useCodes(
|
||||||
user?.type === "corporate" ? user?.id : undefined,
|
user?.type === "corporate" ? user?.id : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const [startDate, setStartDate] = useState<Date | null>(moment("01/01/2023").toDate());
|
||||||
let result = [...codes];
|
const [endDate, setEndDate] = useState<Date | null>(moment().endOf("day").toDate());
|
||||||
if (filteredCorporate)
|
const filteredCodes = useMemo(() => {
|
||||||
result = result.filter((x) => x.creator === filteredCorporate.id);
|
return codes.filter((x) => {
|
||||||
if (filterAvailability)
|
// TODO: if the expiry date is missing, it does not make sense to filter by date
|
||||||
result = result.filter((x) =>
|
// so we need to find a way to handle this edge case
|
||||||
filterAvailability === "in-use" ? !!x.userId : !x.userId,
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
setFilteredCodes(result);
|
return true;
|
||||||
}, [codes, filteredCorporate, filterAvailability]);
|
});
|
||||||
|
}, [codes, startDate, endDate, filteredCorporate, filterAvailability]);
|
||||||
|
|
||||||
const toggleCode = (id: string) => {
|
const toggleCode = (id: string) => {
|
||||||
setSelectedCodes((prev) =>
|
setSelectedCodes((prev) =>
|
||||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleAllCodes = (checked: boolean) => {
|
const toggleAllCodes = (checked: boolean) => {
|
||||||
if (checked)
|
if (checked)
|
||||||
return setSelectedCodes(
|
return setSelectedCodes(
|
||||||
filteredCodes.filter((x) => !x.userId).map((x) => x.code),
|
filteredCodes.filter((x) => !x.userId).map((x) => x.code)
|
||||||
);
|
);
|
||||||
|
|
||||||
return setSelectedCodes([]);
|
return setSelectedCodes([]);
|
||||||
@@ -137,7 +149,7 @@ export default function CodeList({ user }: { user: User }) {
|
|||||||
|
|
||||||
const defaultColumns = [
|
const defaultColumns = [
|
||||||
columnHelper.accessor("code", {
|
columnHelper.accessor("code", {
|
||||||
id: "code",
|
id: "codeCheckbox",
|
||||||
header: () => (
|
header: () => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
disabled={filteredCodes.filter((x) => !x.userId).length === 0}
|
disabled={filteredCodes.filter((x) => !x.userId).length === 0}
|
||||||
@@ -242,18 +254,20 @@ export default function CodeList({ user }: { user: User }) {
|
|||||||
}
|
}
|
||||||
options={users
|
options={users
|
||||||
.filter((x) =>
|
.filter((x) =>
|
||||||
["admin", "developer", "corporate"].includes(x.type),
|
["admin", "developer", "corporate"].includes(x.type)
|
||||||
)
|
)
|
||||||
.map((x) => ({
|
.map((x) => ({
|
||||||
label: `${x.type === "corporate" ? x.corporateInformation?.companyInformation?.name || x.name : x.name} (${
|
label: `${
|
||||||
USER_TYPE_LABELS[x.type]
|
x.type === "corporate"
|
||||||
})`,
|
? x.corporateInformation?.companyInformation?.name || x.name
|
||||||
|
: x.name
|
||||||
|
} (${USER_TYPE_LABELS[x.type]})`,
|
||||||
value: x.id,
|
value: x.id,
|
||||||
user: x,
|
user: x,
|
||||||
}))}
|
}))}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setFilteredCorporate(
|
setFilteredCorporate(
|
||||||
value ? users.find((x) => x.id === value?.value) : undefined,
|
value ? users.find((x) => x.id === value?.value) : undefined
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -267,10 +281,32 @@ export default function CodeList({ user }: { user: User }) {
|
|||||||
]}
|
]}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setFilterAvailability(
|
setFilterAvailability(
|
||||||
value ? (value.value as typeof filterAvailability) : undefined,
|
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>
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<span>{selectedCodes.length} code(s) selected</span>
|
<span>{selectedCodes.length} code(s) selected</span>
|
||||||
@@ -295,7 +331,7 @@ export default function CodeList({ user }: { user: User }) {
|
|||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext(),
|
header.getContext()
|
||||||
)}
|
)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,154 +1,223 @@
|
|||||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
import { useMemo } from "react";
|
||||||
|
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||||
import useExams from "@/hooks/useExams";
|
import useExams from "@/hooks/useExams";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {Module} from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import {Exam} from "@/interfaces/exam";
|
import { Exam } from "@/interfaces/exam";
|
||||||
import {Type, User} from "@/interfaces/user";
|
import { Type, User } from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {getExamById} from "@/utils/exams";
|
import { getExamById } from "@/utils/exams";
|
||||||
import {countExercises} from "@/utils/moduleUtils";
|
import { countExercises } from "@/utils/moduleUtils";
|
||||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
import {
|
||||||
|
createColumnHelper,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize} from "lodash";
|
import { capitalize } from "lodash";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {BsCheck, BsTrash, BsUpload} from "react-icons/bs";
|
import { BsCheck, BsTrash, BsUpload } from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
const CLASSES: {[key in Module]: string} = {
|
const CLASSES: { [key in Module]: string } = {
|
||||||
reading: "text-ielts-reading",
|
reading: "text-ielts-reading",
|
||||||
listening: "text-ielts-listening",
|
listening: "text-ielts-listening",
|
||||||
speaking: "text-ielts-speaking",
|
speaking: "text-ielts-speaking",
|
||||||
writing: "text-ielts-writing",
|
writing: "text-ielts-writing",
|
||||||
level: "text-ielts-level",
|
level: "text-ielts-level",
|
||||||
};
|
};
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<Exam>();
|
const columnHelper = createColumnHelper<Exam>();
|
||||||
|
|
||||||
export default function ExamList({user}: {user: User}) {
|
export default function ExamList({ user }: { user: User }) {
|
||||||
const {exams, reload} = useExams();
|
const { exams, reload } = useExams();
|
||||||
|
const { users } = useUsers();
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
const parsedExams = useMemo(() => {
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
return exams.map((exam) => {
|
||||||
|
if (exam.createdBy) {
|
||||||
|
const user = users.find((u) => u.id === exam.createdBy);
|
||||||
|
if (!user) return exam;
|
||||||
|
|
||||||
const router = useRouter();
|
return {
|
||||||
|
...exam,
|
||||||
|
createdBy: user.type === "developer" ? "system" : user.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const loadExam = async (module: Module, examId: string) => {
|
return exam;
|
||||||
const exam = await getExamById(module, examId.trim());
|
});
|
||||||
if (!exam) {
|
}, [exams, users]);
|
||||||
toast.error("Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID", {
|
|
||||||
toastId: "invalid-exam-id",
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
const setExams = useExamStore((state) => state.setExams);
|
||||||
}
|
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||||
|
|
||||||
setExams([exam]);
|
const router = useRouter();
|
||||||
setSelectedModules([module]);
|
|
||||||
|
|
||||||
router.push("/exercises");
|
const loadExam = async (module: Module, examId: string) => {
|
||||||
};
|
const exam = await getExamById(module, examId.trim());
|
||||||
|
if (!exam) {
|
||||||
|
toast.error(
|
||||||
|
"Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID",
|
||||||
|
{
|
||||||
|
toastId: "invalid-exam-id",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const deleteExam = async (exam: Exam) => {
|
return;
|
||||||
if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return;
|
}
|
||||||
|
|
||||||
axios
|
setExams([exam]);
|
||||||
.delete(`/api/exam/${exam.module}/${exam.id}`)
|
setSelectedModules([module]);
|
||||||
.then(() => toast.success(`Deleted the "${exam.id}" exam`))
|
|
||||||
.catch((reason) => {
|
|
||||||
if (reason.response.status === 404) {
|
|
||||||
toast.error("Exam not found!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reason.response.status === 403) {
|
router.push("/exercises");
|
||||||
toast.error("You do not have permission to delete this exam!");
|
};
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.error("Something went wrong, please try again later.");
|
const deleteExam = async (exam: Exam) => {
|
||||||
})
|
if (
|
||||||
.finally(reload);
|
!confirm(
|
||||||
};
|
`Are you sure you want to delete this ${capitalize(exam.module)} exam?`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
const getTotalExercises = (exam: Exam) => {
|
axios
|
||||||
if (exam.module === "reading" || exam.module === "listening") {
|
.delete(`/api/exam/${exam.module}/${exam.id}`)
|
||||||
return countExercises(exam.parts.flatMap((x) => x.exercises));
|
.then(() => toast.success(`Deleted the "${exam.id}" exam`))
|
||||||
}
|
.catch((reason) => {
|
||||||
|
if (reason.response.status === 404) {
|
||||||
|
toast.error("Exam not found!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return countExercises(exam.exercises);
|
if (reason.response.status === 403) {
|
||||||
};
|
toast.error("You do not have permission to delete this exam!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const defaultColumns = [
|
toast.error("Something went wrong, please try again later.");
|
||||||
columnHelper.accessor("id", {
|
})
|
||||||
header: "ID",
|
.finally(reload);
|
||||||
cell: (info) => info.getValue(),
|
};
|
||||||
}),
|
|
||||||
columnHelper.accessor("module", {
|
|
||||||
header: "Module",
|
|
||||||
cell: (info) => <span className={CLASSES[info.getValue()]}>{capitalize(info.getValue())}</span>,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor((x) => getTotalExercises(x), {
|
|
||||||
header: "Exercises",
|
|
||||||
cell: (info) => info.getValue(),
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("minTimer", {
|
|
||||||
header: "Timer",
|
|
||||||
cell: (info) => <>{info.getValue()} minute(s)</>,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
header: "",
|
|
||||||
id: "actions",
|
|
||||||
cell: ({row}: {row: {original: Exam}}) => {
|
|
||||||
return (
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<div
|
|
||||||
data-tip="Load exam"
|
|
||||||
className="cursor-pointer tooltip"
|
|
||||||
onClick={async () => await loadExam(row.original.module, row.original.id)}>
|
|
||||||
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
|
||||||
</div>
|
|
||||||
{PERMISSIONS.examManagement.delete.includes(user.type) && (
|
|
||||||
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteExam(row.original)}>
|
|
||||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const table = useReactTable({
|
const getTotalExercises = (exam: Exam) => {
|
||||||
data: exams,
|
if (
|
||||||
columns: defaultColumns,
|
exam.module === "reading" ||
|
||||||
getCoreRowModel: getCoreRowModel(),
|
exam.module === "listening" ||
|
||||||
});
|
exam.module === "level"
|
||||||
|
) {
|
||||||
|
return countExercises(exam.parts.flatMap((x) => x.exercises));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return countExercises(exam.exercises);
|
||||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
};
|
||||||
<thead>
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
const defaultColumns = [
|
||||||
<tr key={headerGroup.id}>
|
columnHelper.accessor("id", {
|
||||||
{headerGroup.headers.map((header) => (
|
header: "ID",
|
||||||
<th className="p-4 text-left" key={header.id}>
|
cell: (info) => info.getValue(),
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
}),
|
||||||
</th>
|
columnHelper.accessor("module", {
|
||||||
))}
|
header: "Module",
|
||||||
</tr>
|
cell: (info) => (
|
||||||
))}
|
<span className={CLASSES[info.getValue()]}>
|
||||||
</thead>
|
{capitalize(info.getValue())}
|
||||||
<tbody className="px-2">
|
</span>
|
||||||
{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) => (
|
columnHelper.accessor((x) => getTotalExercises(x), {
|
||||||
<td className="px-4 py-2" key={cell.id}>
|
header: "Exercises",
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
cell: (info) => info.getValue(),
|
||||||
</td>
|
}),
|
||||||
))}
|
columnHelper.accessor("minTimer", {
|
||||||
</tr>
|
header: "Timer",
|
||||||
))}
|
cell: (info) => <>{info.getValue()} minute(s)</>,
|
||||||
</tbody>
|
}),
|
||||||
</table>
|
columnHelper.accessor("createdAt", {
|
||||||
);
|
header: "Created At",
|
||||||
|
cell: (info) => {
|
||||||
|
const value = info.getValue();
|
||||||
|
if (value) {
|
||||||
|
return new Date(value).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("createdBy", {
|
||||||
|
header: "Created By",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
header: "",
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }: { row: { original: Exam } }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div
|
||||||
|
data-tip="Load exam"
|
||||||
|
className="cursor-pointer tooltip"
|
||||||
|
onClick={async () =>
|
||||||
|
await loadExam(row.original.module, row.original.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
|
</div>
|
||||||
|
{PERMISSIONS.examManagement.delete.includes(user.type) && (
|
||||||
|
<div
|
||||||
|
data-tip="Delete"
|
||||||
|
className="cursor-pointer tooltip"
|
||||||
|
onClick={() => deleteExam(row.original)}
|
||||||
|
>
|
||||||
|
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: parsedExams,
|
||||||
|
columns: defaultColumns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
|||||||
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
|
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
|
||||||
const filteredUsers = emailUsers.filter(
|
const filteredUsers = emailUsers.filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
((user.type === "developer" || user.type === "admin" || user.type === "corporate") &&
|
((user.type === "developer" || user.type === "admin" || user.type === "corporate" || user.type === "mastercorporate") &&
|
||||||
(x?.type === "student" || x?.type === "teacher")) ||
|
(x?.type === "student" || x?.type === "teacher")) ||
|
||||||
(user.type === "teacher" && x?.type === "student"),
|
(user.type === "teacher" && x?.type === "student"),
|
||||||
);
|
);
|
||||||
@@ -189,7 +189,7 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const filterTypes = ["corporate", "teacher"];
|
const filterTypes = ["corporate", "teacher", "mastercorporate"];
|
||||||
|
|
||||||
export default function GroupList({user}: {user: User}) {
|
export default function GroupList({user}: {user: User}) {
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
@@ -197,10 +197,10 @@ export default function GroupList({user}: {user: User}) {
|
|||||||
const [filterByUser, setFilterByUser] = useState(false);
|
const [filterByUser, setFilterByUser] = useState(false);
|
||||||
|
|
||||||
const {users} = useUsers();
|
const {users} = useUsers();
|
||||||
const {groups, reload} = useGroups(user && filterTypes.includes(user?.type) ? user.id : undefined);
|
const {groups, reload} = useGroups(user && filterTypes.includes(user?.type) ? user.id : undefined, user?.type);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user && (user.type === "corporate" || user.type === "teacher")) {
|
if (user && (['corporate', 'teacher', 'mastercorporate'].includes(user.type))) {
|
||||||
setFilterByUser(true);
|
setFilterByUser(true);
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -35,14 +35,14 @@ export default function ExamPage({page}: Props) {
|
|||||||
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
|
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
|
||||||
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
|
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
|
||||||
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]);
|
const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState<string[]>([]);
|
||||||
|
const [inactivityTimer, setInactivityTimer] = useState(0);
|
||||||
|
const [totalInactivity, setTotalInactivity] = useState(0);
|
||||||
const [timeSpent, setTimeSpent] = useState(0);
|
const [timeSpent, setTimeSpent] = useState(0);
|
||||||
|
|
||||||
const resetStore = useExamStore((state) => state.reset);
|
const resetStore = useExamStore((state) => state.reset);
|
||||||
const assignment = useExamStore((state) => state.assignment);
|
const assignment = useExamStore((state) => state.assignment);
|
||||||
const initialTimeSpent = useExamStore((state) => state.timeSpent);
|
const initialTimeSpent = useExamStore((state) => state.timeSpent);
|
||||||
|
|
||||||
const examStore = useExamStore;
|
|
||||||
|
|
||||||
const {exam, setExam} = useExamStore((state) => state);
|
const {exam, setExam} = useExamStore((state) => state);
|
||||||
const {exams, setExams} = useExamStore((state) => state);
|
const {exams, setExams} = useExamStore((state) => state);
|
||||||
const {sessionId, setSessionId} = useExamStore((state) => state);
|
const {sessionId, setSessionId} = useExamStore((state) => state);
|
||||||
@@ -53,10 +53,20 @@ export default function ExamPage({page}: Props) {
|
|||||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||||
const {showSolutions, setShowSolutions} = useExamStore((state) => state);
|
const {showSolutions, setShowSolutions} = useExamStore((state) => state);
|
||||||
const {selectedModules, setSelectedModules} = useExamStore((state) => state);
|
const {selectedModules, setSelectedModules} = useExamStore((state) => state);
|
||||||
|
const {inactivity, setInactivity} = useExamStore((state) => state);
|
||||||
|
|
||||||
const {user} = useUser({redirectTo: "/login"});
|
const {user} = useUser({redirectTo: "/login"});
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
const resetInactivityTimer = () => {
|
||||||
|
setInactivityTimer((prev) => {
|
||||||
|
if (moduleIndex >= selectedModules.length || moduleIndex === -1) return 0;
|
||||||
|
if (prev >= 120) setTotalInactivity((totalPrev) => totalPrev + prev);
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
resetStore();
|
resetStore();
|
||||||
setVariant("full");
|
setVariant("full");
|
||||||
@@ -66,8 +76,21 @@ export default function ExamPage({page}: Props) {
|
|||||||
setIsEvaluationLoading(false);
|
setIsEvaluationLoading(false);
|
||||||
setStatsAwaitingEvaluation([]);
|
setStatsAwaitingEvaluation([]);
|
||||||
setTimeSpent(0);
|
setTimeSpent(0);
|
||||||
|
setInactivity(0);
|
||||||
|
|
||||||
|
document.removeEventListener("keydown", resetInactivityTimer);
|
||||||
|
document.removeEventListener("mousemove", resetInactivityTimer);
|
||||||
|
document.removeEventListener("mousedown", resetInactivityTimer);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (moduleIndex >= selectedModules.length || moduleIndex === -1 || showSolutions) {
|
||||||
|
document.removeEventListener("keydown", resetInactivityTimer);
|
||||||
|
document.removeEventListener("mousemove", resetInactivityTimer);
|
||||||
|
document.removeEventListener("mousedown", resetInactivityTimer);
|
||||||
|
}
|
||||||
|
}, [moduleIndex, resetInactivityTimer, selectedModules.length, showSolutions]);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
const saveSession = async () => {
|
const saveSession = async () => {
|
||||||
console.log("Saving your session...");
|
console.log("Saving your session...");
|
||||||
@@ -81,6 +104,7 @@ export default function ExamPage({page}: Props) {
|
|||||||
selectedModules,
|
selectedModules,
|
||||||
assignment,
|
assignment,
|
||||||
timeSpent,
|
timeSpent,
|
||||||
|
inactivity: totalInactivity,
|
||||||
exams,
|
exams,
|
||||||
exam,
|
exam,
|
||||||
partIndex,
|
partIndex,
|
||||||
@@ -90,7 +114,8 @@ export default function ExamPage({page}: Props) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => setTimeSpent((prev) => prev + initialTimeSpent), [initialTimeSpent]);
|
useEffect(() => setTimeSpent(initialTimeSpent), [initialTimeSpent]);
|
||||||
|
useEffect(() => setTotalInactivity(inactivity), [inactivity]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userSolutions.length === 0 && exams.length > 0) {
|
if (userSolutions.length === 0 && exams.length > 0) {
|
||||||
@@ -108,14 +133,22 @@ export default function ExamPage({page}: Props) {
|
|||||||
!!exam &&
|
!!exam &&
|
||||||
timeSpent > 0 &&
|
timeSpent > 0 &&
|
||||||
!showSolutions &&
|
!showSolutions &&
|
||||||
moduleIndex < selectedModules.length
|
moduleIndex < selectedModules.length &&
|
||||||
|
selectedModules[moduleIndex] !== "speaking"
|
||||||
)
|
)
|
||||||
saveSession();
|
saveSession();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [assignment, exam, exams, moduleIndex, selectedModules, sessionId, userSolutions, user, exerciseIndex, partIndex, questionIndex]);
|
}, [assignment, exam, exams, moduleIndex, selectedModules, sessionId, userSolutions, user, exerciseIndex, partIndex, questionIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (timeSpent % 20 === 0 && timeSpent > 0 && moduleIndex < selectedModules.length && !showSolutions) saveSession();
|
if (
|
||||||
|
timeSpent % 20 === 0 &&
|
||||||
|
timeSpent > 0 &&
|
||||||
|
moduleIndex < selectedModules.length &&
|
||||||
|
selectedModules[moduleIndex] !== "speaking" &&
|
||||||
|
!showSolutions
|
||||||
|
)
|
||||||
|
saveSession();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [timeSpent]);
|
}, [timeSpent]);
|
||||||
|
|
||||||
@@ -144,7 +177,34 @@ export default function ExamPage({page}: Props) {
|
|||||||
}, [selectedModules.length]);
|
}, [selectedModules.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showSolutions) setModuleIndex(-1);
|
if (selectedModules.length > 0 && !showSolutions && inactivityTimer === 0) {
|
||||||
|
const inactivityInterval = setInterval(() => {
|
||||||
|
setInactivityTimer((prev) => prev + 1);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(inactivityInterval);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedModules.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener("keydown", resetInactivityTimer);
|
||||||
|
document.addEventListener("mousemove", resetInactivityTimer);
|
||||||
|
document.addEventListener("mousedown", resetInactivityTimer);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", resetInactivityTimer);
|
||||||
|
document.removeEventListener("mousemove", resetInactivityTimer);
|
||||||
|
document.removeEventListener("mousedown", resetInactivityTimer);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showSolutions) {
|
||||||
|
setModuleIndex(-1);
|
||||||
|
}
|
||||||
}, [setModuleIndex, showSolutions]);
|
}, [setModuleIndex, showSolutions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -190,6 +250,7 @@ export default function ExamPage({page}: Props) {
|
|||||||
...solution,
|
...solution,
|
||||||
id: solution.id || uuidv4(),
|
id: solution.id || uuidv4(),
|
||||||
timeSpent,
|
timeSpent,
|
||||||
|
inactivity: totalInactivity,
|
||||||
session: sessionId,
|
session: sessionId,
|
||||||
exam: solution.exam!,
|
exam: solution.exam!,
|
||||||
module: solution.module!,
|
module: solution.module!,
|
||||||
@@ -251,7 +312,7 @@ export default function ExamPage({page}: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateExamWithUserSolutions = (exam: Exam): Exam => {
|
const updateExamWithUserSolutions = (exam: Exam): Exam => {
|
||||||
if (exam.module === "reading" || exam.module === "listening") {
|
if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") {
|
||||||
const parts = exam.parts.map((p) =>
|
const parts = exam.parts.map((p) =>
|
||||||
Object.assign(p, {
|
Object.assign(p, {
|
||||||
exercises: p.exercises.map((x) =>
|
exercises: p.exercises.map((x) =>
|
||||||
@@ -296,7 +357,7 @@ export default function ExamPage({page}: Props) {
|
|||||||
exercise,
|
exercise,
|
||||||
solutions.find((x) => x.exercise === exercise.id)!,
|
solutions.find((x) => x.exercise === exercise.id)!,
|
||||||
evaluationID,
|
evaluationID,
|
||||||
index === 0 ? 1 : 2,
|
index + 1,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -392,6 +453,11 @@ export default function ExamPage({page}: Props) {
|
|||||||
isLoading={isEvaluationLoading}
|
isLoading={isEvaluationLoading}
|
||||||
user={user!}
|
user={user!}
|
||||||
modules={selectedModules}
|
modules={selectedModules}
|
||||||
|
solutions={userSolutions}
|
||||||
|
information={{
|
||||||
|
timeSpent,
|
||||||
|
inactivity: totalInactivity,
|
||||||
|
}}
|
||||||
onViewResults={(index?: number) => {
|
onViewResults={(index?: number) => {
|
||||||
setShowSolutions(true);
|
setShowSolutions(true);
|
||||||
setModuleIndex(index || 0);
|
setModuleIndex(index || 0);
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
|
import FillBlanksEdit from "@/components/Generation/fill.blanks.edit";
|
||||||
|
import MultipleChoiceEdit from "@/components/Generation/multiple.choice.edit";
|
||||||
|
import WriteBlankEdits from "@/components/Generation/write.blanks.edit";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
import {Difficulty, LevelExam, MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
import {
|
||||||
|
Difficulty,
|
||||||
|
LevelExam,
|
||||||
|
MultipleChoiceExercise,
|
||||||
|
MultipleChoiceQuestion,
|
||||||
|
LevelPart,
|
||||||
|
FillBlanksExercise,
|
||||||
|
WriteBlanksExercise,
|
||||||
|
Exercise,
|
||||||
|
} from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {getExamById} from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
import {playSound} from "@/utils/sound";
|
import {playSound} from "@/utils/sound";
|
||||||
@@ -8,26 +21,48 @@ import axios from "axios";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize, sample} from "lodash";
|
import {capitalize, sample} from "lodash";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {BsArrowRepeat, BsCheck, BsPencilSquare, BsX} from "react-icons/bs";
|
import {BsArrowRepeat, BsCheck, BsPencilSquare, BsX} from "react-icons/bs";
|
||||||
|
import reactStringReplace from "react-string-replace";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import {v4} from "uuid";
|
import {v4} from "uuid";
|
||||||
|
|
||||||
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||||
|
const TYPES: {[key: string]: string} = {
|
||||||
|
multiple_choice_4: "Multiple Choice",
|
||||||
|
multiple_choice_blank_space: "Multiple Choice - Blank Space",
|
||||||
|
multiple_choice_underlined: "Multiple Choice - Underlined",
|
||||||
|
blank_space_text: "Blank Space",
|
||||||
|
reading_passage_utas: "Reading Passage",
|
||||||
|
};
|
||||||
|
|
||||||
|
type LevelSection = {type: string; quantity: number; topic?: string; part?: LevelPart};
|
||||||
|
|
||||||
const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion; onUpdate: (question: MultipleChoiceQuestion) => void}) => {
|
const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion; onUpdate: (question: MultipleChoiceQuestion) => void}) => {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [options, setOptions] = useState(question.options);
|
const [options, setOptions] = useState(question.options);
|
||||||
|
const [answer, setAnswer] = useState(question.solution);
|
||||||
|
|
||||||
|
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 key={question.id} className="flex flex-col gap-1">
|
<div key={question.id} className="flex flex-col gap-1">
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
{question.id}. {question.prompt}{" "}
|
<>
|
||||||
|
{question.id}. <span>{renderPrompt(question.prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||||
|
</>
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{question.options.map((option, index) => (
|
{question.options.map((option, index) => (
|
||||||
<span key={option.id} className={clsx(question.solution === option.id && "font-bold")}>
|
<span key={option.id} className={clsx(answer === option.id && "font-bold")}>
|
||||||
<span className={clsx("font-semibold", question.solution === option.id ? "text-mti-green-light" : "text-ielts-level")}>
|
<span
|
||||||
|
className={clsx("font-semibold", answer === option.id ? "text-mti-green-light" : "text-ielts-level")}
|
||||||
|
onClick={() => setAnswer(option.id)}>
|
||||||
({option.id})
|
({option.id})
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
@@ -54,7 +89,7 @@ const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onUpdate({...question, options});
|
onUpdate({...question, options, solution: answer});
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
}}
|
}}
|
||||||
className="p-2 border border-neutral-300 bg-white rounded-xl hover:drop-shadow transition ease-in-out duration-300">
|
className="p-2 border border-neutral-300 bg-white rounded-xl hover:drop-shadow transition ease-in-out duration-300">
|
||||||
@@ -72,61 +107,112 @@ const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaskTab = ({exam, difficulty, setExam}: {exam?: LevelExam; difficulty: Difficulty; setExam: (exam: LevelExam) => void}) => {
|
const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (section: LevelSection) => void}) => {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const generate = () => {
|
|
||||||
const url = new URLSearchParams();
|
|
||||||
url.append("difficulty", difficulty);
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
axios
|
|
||||||
.get(`/api/exam/level/generate/level?${url.toString()}`)
|
|
||||||
.then((result) => {
|
|
||||||
playSound(typeof result.data === "string" ? "error" : "check");
|
|
||||||
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
|
||||||
setExam(result.data);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.log(error);
|
|
||||||
toast.error("Something went wrong!");
|
|
||||||
})
|
|
||||||
.finally(() => setIsLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const onUpdate = (question: MultipleChoiceQuestion) => {
|
const onUpdate = (question: MultipleChoiceQuestion) => {
|
||||||
if (!exam) return;
|
if (!section) return;
|
||||||
|
|
||||||
const updatedExam = {
|
const updatedExam = {
|
||||||
...exam,
|
...section,
|
||||||
exercises: exam.exercises.map((x) => ({
|
exercises: section.part?.exercises.map((x) => ({
|
||||||
...x,
|
...x,
|
||||||
questions: (x as MultipleChoiceExercise).questions.map((q) => (q.id === question.id ? question : q)),
|
questions: (x as MultipleChoiceExercise).questions.map((q) => (q.id === question.id ? question : q)),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
console.log(updatedExam);
|
setSection(updatedExam as any);
|
||||||
setExam(updatedExam as any);
|
};
|
||||||
|
|
||||||
|
const renderExercise = (exercise: Exercise) => {
|
||||||
|
if (exercise.type === "multipleChoice")
|
||||||
|
return (
|
||||||
|
<div key={exercise.id} className="w-full h-full flex flex-col gap-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-xl font-semibold">Multiple Choice</span>
|
||||||
|
<span className="rounded-xl bg-white border border-ielts-level p-1 px-4 w-fit">{exercise.questions.length} questions</span>
|
||||||
|
</div>
|
||||||
|
<MultipleChoiceEdit
|
||||||
|
exercise={exercise}
|
||||||
|
key={exercise.id}
|
||||||
|
updateExercise={(data: any) =>
|
||||||
|
setSection({
|
||||||
|
...section,
|
||||||
|
part: {...section.part!, exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x))},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (exercise.type === "fillBlanks")
|
||||||
|
return (
|
||||||
|
<div key={exercise.id} className="w-full h-full flex flex-col gap-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-xl font-semibold">Fill Blanks</span>
|
||||||
|
</div>
|
||||||
|
<span>{exercise.prompt}</span>
|
||||||
|
<FillBlanksEdit
|
||||||
|
exercise={exercise}
|
||||||
|
key={exercise.id}
|
||||||
|
updateExercise={(data: any) =>
|
||||||
|
setSection({
|
||||||
|
...section,
|
||||||
|
part: {...section.part!, exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x))},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (exercise.type === "writeBlanks")
|
||||||
|
return (
|
||||||
|
<div key={exercise.id} className="w-full h-full flex flex-col gap-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-xl font-semibold">Write Blanks</span>
|
||||||
|
</div>
|
||||||
|
<span>{exercise.prompt}</span>
|
||||||
|
<WriteBlankEdits
|
||||||
|
exercise={exercise}
|
||||||
|
key={exercise.id}
|
||||||
|
updateExercise={(data: any) =>
|
||||||
|
setSection({
|
||||||
|
...section,
|
||||||
|
part: {...section.part!, exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x))},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Panel className="w-full bg-ielts-level/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-level/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
|
||||||
<div className="flex gap-4 items-end">
|
<div className="flex flex-col gap-4">
|
||||||
<button
|
<div className="flex gap-4 w-full">
|
||||||
onClick={generate}
|
<div className="flex flex-col gap-3 w-full">
|
||||||
disabled={isLoading}
|
<label className="font-normal text-base text-mti-gray-dim">Exercise Type</label>
|
||||||
className={clsx(
|
<Select
|
||||||
"bg-ielts-level/70 border border-ielts-level text-white w-full px-6 py-6 rounded-xl h-[70px]",
|
options={Object.keys(TYPES).map((key) => ({value: key, label: TYPES[key]}))}
|
||||||
"hover:bg-ielts-level disabled:bg-ielts-level/40 disabled:cursor-not-allowed",
|
onChange={(e) => setSection({...section, type: e!.value!})}
|
||||||
"transition ease-in-out duration-300",
|
value={{value: section?.type || "multiple_choice_4", label: TYPES[section?.type || "multiple_choice_4"]}}
|
||||||
)}>
|
/>
|
||||||
{isLoading ? (
|
</div>
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
<label className="font-normal text-base text-mti-gray-dim">Number of Questions</label>
|
||||||
</div>
|
<Input
|
||||||
) : (
|
type="number"
|
||||||
"Generate"
|
name="Number of Questions"
|
||||||
)}
|
onChange={(v) => setSection({...section, quantity: parseInt(v)})}
|
||||||
</button>
|
value={section?.quantity || 10}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{section?.type === "reading_passage_utas" && (
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Topic</label>
|
||||||
|
<Input type="text" name="Topic" onChange={(v) => setSection({...section, topic: v})} value={section?.topic} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="w-fit h-fit mt-12 self-center animate-pulse flex flex-col gap-8 items-center">
|
<div className="w-fit h-fit mt-12 self-center animate-pulse flex flex-col gap-8 items-center">
|
||||||
@@ -134,29 +220,10 @@ const TaskTab = ({exam, difficulty, setExam}: {exam?: LevelExam; difficulty: Dif
|
|||||||
<span className={clsx("font-bold text-2xl text-ielts-level")}>Generating...</span>
|
<span className={clsx("font-bold text-2xl text-ielts-level")}>Generating...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{exam && (
|
{section?.part && (
|
||||||
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide h-full">
|
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide h-full">
|
||||||
{exam.exercises
|
{section.part.context && <div>{section.part.context}</div>}
|
||||||
.filter((x) => x.type === "multipleChoice")
|
{section.part.exercises.map(renderExercise)}
|
||||||
.map((ex) => {
|
|
||||||
const exercise = ex as MultipleChoiceExercise;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={ex.id} className="w-full h-full flex flex-col gap-2">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<span className="text-xl font-semibold">Multiple Choice</span>
|
|
||||||
<span className="rounded-xl bg-white border border-ielts-level p-1 px-4 w-fit">
|
|
||||||
{exercise.questions.length} questions
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{exercise.questions.map((question) => (
|
|
||||||
<QuestionDisplay question={question} onUpdate={onUpdate} key={question.id} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
@@ -167,7 +234,14 @@ const LevelGeneration = () => {
|
|||||||
const [generatedExam, setGeneratedExam] = useState<LevelExam>();
|
const [generatedExam, setGeneratedExam] = useState<LevelExam>();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [resultingExam, setResultingExam] = useState<LevelExam>();
|
const [resultingExam, setResultingExam] = useState<LevelExam>();
|
||||||
|
const [timer, setTimer] = useState(10);
|
||||||
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||||
|
const [numberOfParts, setNumberOfParts] = useState(1);
|
||||||
|
const [parts, setParts] = useState<LevelSection[]>([{quantity: 10, type: "multiple_choice_4"}]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setParts((prev) => Array.from(Array(numberOfParts)).map((_, i) => (!!prev.at(i) ? prev.at(i)! : {quantity: 10, type: "multiple_choice_4"})));
|
||||||
|
}, [numberOfParts]);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -190,6 +264,156 @@ const LevelGeneration = () => {
|
|||||||
router.push("/exercises");
|
router.push("/exercises");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const generateExam = () => {
|
||||||
|
if (parts.length === 0) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
let body: any = {};
|
||||||
|
parts.forEach((part, index) => {
|
||||||
|
body[`exercise_${index + 1}_type`] = part.type;
|
||||||
|
body[`exercise_${index + 1}_qty`] = part.quantity;
|
||||||
|
|
||||||
|
if (part.topic) body[`exercise_${index + 1}_topic`] = part.topic;
|
||||||
|
if (part.type === "reading_passage_utas") {
|
||||||
|
body[`exercise_${index + 1}_sa_qty`] = Math.floor(part.quantity / 2);
|
||||||
|
body[`exercise_${index + 1}_mc_qty`] = Math.ceil(part.quantity / 2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let newParts = [...parts];
|
||||||
|
|
||||||
|
axios
|
||||||
|
.post<{exercises: {[key: string]: any}}>("/api/exam/level/generate/level", {nr_exercises: numberOfParts, ...body})
|
||||||
|
.then((result) => {
|
||||||
|
console.log(result.data);
|
||||||
|
|
||||||
|
playSound(typeof result.data === "string" ? "error" : "check");
|
||||||
|
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
||||||
|
|
||||||
|
const exam: LevelExam = {
|
||||||
|
id: v4(),
|
||||||
|
minTimer: timer,
|
||||||
|
module: "level",
|
||||||
|
difficulty,
|
||||||
|
variant: "full",
|
||||||
|
isDiagnostic: false,
|
||||||
|
parts: parts
|
||||||
|
.map((part, index) => {
|
||||||
|
const currentExercise = result.data.exercises[`exercise_${index + 1}`] as any;
|
||||||
|
|
||||||
|
if (
|
||||||
|
part.type === "multiple_choice_4" ||
|
||||||
|
part.type === "multiple_choice_blank_space" ||
|
||||||
|
part.type === "multiple_choice_underlined"
|
||||||
|
) {
|
||||||
|
const exercise: MultipleChoiceExercise = {
|
||||||
|
id: v4(),
|
||||||
|
prompt:
|
||||||
|
part.type === "multiple_choice_underlined"
|
||||||
|
? "Select the wrong part of the sentence."
|
||||||
|
: "Select the appropriate option.",
|
||||||
|
questions: currentExercise.questions.map((x: any) => ({...x, variant: "text"})),
|
||||||
|
type: "multipleChoice",
|
||||||
|
userSolutions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
exercises: [exercise],
|
||||||
|
};
|
||||||
|
|
||||||
|
newParts = newParts.map((p, i) =>
|
||||||
|
i === index
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
part: item,
|
||||||
|
}
|
||||||
|
: p,
|
||||||
|
);
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type === "blank_space_text") {
|
||||||
|
console.log({currentExercise});
|
||||||
|
|
||||||
|
const exercise: WriteBlanksExercise = {
|
||||||
|
id: v4(),
|
||||||
|
prompt: "Complete the text below.",
|
||||||
|
text: currentExercise.text,
|
||||||
|
maxWords: 3,
|
||||||
|
solutions: currentExercise.words.map((x: any) => ({id: x.id, solution: [x.text]})),
|
||||||
|
type: "writeBlanks",
|
||||||
|
userSolutions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
exercises: [exercise],
|
||||||
|
};
|
||||||
|
|
||||||
|
newParts = newParts.map((p, i) =>
|
||||||
|
i === index
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
part: item,
|
||||||
|
}
|
||||||
|
: p,
|
||||||
|
);
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mcExercise: MultipleChoiceExercise = {
|
||||||
|
id: v4(),
|
||||||
|
prompt: "Select the appropriate option.",
|
||||||
|
questions: currentExercise.exercises.multipleChoice.questions.map((x: any) => ({...x, variant: "text"})),
|
||||||
|
type: "multipleChoice",
|
||||||
|
userSolutions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const wbExercise: WriteBlanksExercise = {
|
||||||
|
id: v4(),
|
||||||
|
prompt: "Complete the notes below.",
|
||||||
|
maxWords: 3,
|
||||||
|
text: currentExercise.exercises.shortAnswer.map((x: any) => `${x.question} {{${x.id}}}`).join("\n"),
|
||||||
|
solutions: currentExercise.exercises.shortAnswer.map((x: any) => ({
|
||||||
|
id: x.id,
|
||||||
|
solution: x.possible_answers,
|
||||||
|
})),
|
||||||
|
type: "writeBlanks",
|
||||||
|
userSolutions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
context: currentExercise.text.content,
|
||||||
|
exercises: [mcExercise, wbExercise],
|
||||||
|
};
|
||||||
|
|
||||||
|
newParts = newParts.map((p, i) =>
|
||||||
|
i === index
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
part: item,
|
||||||
|
}
|
||||||
|
: p,
|
||||||
|
);
|
||||||
|
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
.filter((x) => !!x) as LevelPart[],
|
||||||
|
};
|
||||||
|
|
||||||
|
setParts(newParts);
|
||||||
|
setGeneratedExam(exam);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.log(error);
|
||||||
|
playSound("error");
|
||||||
|
|
||||||
|
toast.error("Something went wrong, please try again later!");
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
const submitExam = () => {
|
const submitExam = () => {
|
||||||
if (!generatedExam) {
|
if (!generatedExam) {
|
||||||
toast.error("Please generate all tasks before submitting");
|
toast.error("Please generate all tasks before submitting");
|
||||||
@@ -197,12 +421,10 @@ const LevelGeneration = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const exam: LevelExam = {
|
|
||||||
|
const exam = {
|
||||||
...generatedExam,
|
...generatedExam,
|
||||||
isDiagnostic: false,
|
parts: generatedExam.parts.map((p, i) => ({...p, exercises: parts[i].part!.exercises})),
|
||||||
minTimer: 25,
|
|
||||||
module: "level",
|
|
||||||
id: v4(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
axios
|
axios
|
||||||
@@ -224,32 +446,55 @@ const LevelGeneration = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex gap-4 w-1/2">
|
<div className="flex gap-4 w-full">
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||||
<Select
|
<Select
|
||||||
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
|
options={DIFFICULTIES.map((x) => ({
|
||||||
|
value: x,
|
||||||
|
label: capitalize(x),
|
||||||
|
}))}
|
||||||
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
||||||
value={{value: difficulty, label: capitalize(difficulty)}}
|
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Number of Parts</label>
|
||||||
|
<Input type="number" name="Number of Parts" onChange={(v) => setNumberOfParts(parseInt(v))} value={numberOfParts} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Timer (in minutes)</label>
|
||||||
|
<Input type="number" name="Timer (in minutes)" onChange={(v) => setTimer(parseInt(v))} value={timer} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
|
||||||
<Tab
|
{Array.from(Array(numberOfParts), (_, index) => index).map((index) => (
|
||||||
className={({selected}) =>
|
<Tab
|
||||||
clsx(
|
key={index}
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-level/70",
|
className={({selected}) =>
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-level focus:outline-none focus:ring-2",
|
clsx(
|
||||||
"transition duration-300 ease-in-out",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-level/70",
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-level",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-level 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-level",
|
||||||
Exam
|
)
|
||||||
</Tab>
|
}>
|
||||||
|
Part {index + 1}
|
||||||
|
</Tab>
|
||||||
|
))}
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels>
|
<Tab.Panels>
|
||||||
<TaskTab difficulty={difficulty} exam={generatedExam} setExam={setGeneratedExam} />
|
{Array.from(Array(numberOfParts), (_, index) => index).map((index) => (
|
||||||
|
<TaskTab
|
||||||
|
key={index}
|
||||||
|
section={parts[index]}
|
||||||
|
setSection={(part) => {
|
||||||
|
console.log(part);
|
||||||
|
setParts((prev) => prev.map((x, i) => (i === index ? part : x)));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
<div className="w-full flex justify-end gap-4">
|
<div className="w-full flex justify-end gap-4">
|
||||||
@@ -265,6 +510,24 @@ const LevelGeneration = () => {
|
|||||||
Perform Exam
|
Perform Exam
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
disabled={parts.length === 0 || isLoading}
|
||||||
|
data-tip="Please generate all three passages"
|
||||||
|
onClick={generateExam}
|
||||||
|
className={clsx(
|
||||||
|
"bg-ielts-level/70 border border-ielts-level text-white w-full max-w-[200px] rounded-xl h-[70px] self-end",
|
||||||
|
"hover:bg-ielts-level disabled:bg-ielts-level/40 disabled:cursor-not-allowed",
|
||||||
|
"transition ease-in-out duration-300",
|
||||||
|
parts.length === 0 && "tooltip",
|
||||||
|
)}>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"Generate"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
disabled={!generatedExam || isLoading}
|
disabled={!generatedExam || isLoading}
|
||||||
data-tip="Please generate all three passages"
|
data-tip="Please generate all three passages"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import MultipleChoiceEdit from "@/components/Generation/multiple.choice.edit";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
import {Difficulty, Exercise, ListeningExam} from "@/interfaces/exam";
|
import {Difficulty, Exercise, ListeningExam} from "@/interfaces/exam";
|
||||||
@@ -10,27 +11,50 @@ import axios from "axios";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize, sample} from "lodash";
|
import {capitalize, sample} from "lodash";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState, Dispatch, SetStateAction} from "react";
|
||||||
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
|
import WriteBlanksEdit from "@/components/Generation/write.blanks.edit";
|
||||||
|
import {generate} from "random-words";
|
||||||
|
|
||||||
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||||
|
|
||||||
|
const MULTIPLE_CHOICE = {type: "multipleChoice", label: "Multiple Choice"};
|
||||||
|
const WRITE_BLANKS_QUESTIONS = {
|
||||||
|
type: "writeBlanksQuestions",
|
||||||
|
label: "Write the Blanks: Questions",
|
||||||
|
};
|
||||||
|
const WRITE_BLANKS_FILL = {
|
||||||
|
type: "writeBlanksFill",
|
||||||
|
label: "Write the Blanks: Fill",
|
||||||
|
};
|
||||||
|
const WRITE_BLANKS_FORM = {
|
||||||
|
type: "writeBlanksForm",
|
||||||
|
label: "Write the Blanks: Form",
|
||||||
|
};
|
||||||
|
const MULTIPLE_CHOICE_3 = {
|
||||||
|
type: "multipleChoice3Options",
|
||||||
|
label: "Multiple Choice",
|
||||||
|
};
|
||||||
|
|
||||||
const PartTab = ({
|
const PartTab = ({
|
||||||
part,
|
part,
|
||||||
types,
|
|
||||||
difficulty,
|
difficulty,
|
||||||
|
availableTypes,
|
||||||
index,
|
index,
|
||||||
setPart,
|
setPart,
|
||||||
|
updatePart,
|
||||||
}: {
|
}: {
|
||||||
part?: ListeningPart;
|
part?: ListeningPart;
|
||||||
difficulty: Difficulty;
|
difficulty: Difficulty;
|
||||||
types: string[];
|
availableTypes: {type: string; label: string}[];
|
||||||
index: number;
|
index: number;
|
||||||
setPart: (part?: ListeningPart) => void;
|
setPart: (part?: ListeningPart) => void;
|
||||||
|
updatePart: Dispatch<SetStateAction<ListeningPart | undefined>>;
|
||||||
}) => {
|
}) => {
|
||||||
const [topic, setTopic] = useState("");
|
const [topic, setTopic] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [types, setTypes] = useState<string[]>([]);
|
||||||
|
|
||||||
const generate = () => {
|
const generate = () => {
|
||||||
const url = new URLSearchParams();
|
const url = new URLSearchParams();
|
||||||
@@ -55,8 +79,85 @@ const PartTab = ({
|
|||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderExercises = () => {
|
||||||
|
return part?.exercises.map((exercise) => {
|
||||||
|
switch (exercise.type) {
|
||||||
|
case "multipleChoice":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Exercise: Multiple Choice</h1>
|
||||||
|
<MultipleChoiceEdit
|
||||||
|
exercise={exercise}
|
||||||
|
key={exercise.id}
|
||||||
|
updateExercise={(data: any) =>
|
||||||
|
updatePart((part?: ListeningPart) => {
|
||||||
|
if (part) {
|
||||||
|
const exercises = part.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x)) as Exercise[];
|
||||||
|
const updatedPart = {
|
||||||
|
...part,
|
||||||
|
exercises,
|
||||||
|
} as ListeningPart;
|
||||||
|
return updatedPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
return part;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
// TODO: This might be broken as they all returns the same
|
||||||
|
case "writeBlanks":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Exercise: Write Blanks</h1>
|
||||||
|
<WriteBlanksEdit
|
||||||
|
exercise={exercise}
|
||||||
|
key={exercise.id}
|
||||||
|
updateExercise={(data: any) => {
|
||||||
|
updatePart((part?: ListeningPart) => {
|
||||||
|
if (part) {
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
exercises: part.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x)),
|
||||||
|
} as ListeningPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleType = (type: string) => setTypes((prev) => (prev.includes(type) ? [...prev.filter((x) => x !== type)] : [...prev, type]));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Panel className="w-full bg-ielts-listening/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-listening/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Exercises</label>
|
||||||
|
<div className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
|
||||||
|
{availableTypes.map((x) => (
|
||||||
|
<span
|
||||||
|
onClick={() => toggleType(x.type)}
|
||||||
|
key={x.type}
|
||||||
|
className={clsx(
|
||||||
|
"px-6 py-4 w-64 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
!types.includes(x.type)
|
||||||
|
? "bg-white border-mti-gray-platinum"
|
||||||
|
: "bg-ielts-listening/70 border-ielts-listening text-white",
|
||||||
|
)}>
|
||||||
|
{x.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex gap-4 items-end">
|
<div className="flex gap-4 items-end">
|
||||||
<Input type="text" placeholder="Grand Canyon..." name="topic" label="Topic" onChange={setTopic} roundness="xl" defaultValue={topic} />
|
<Input type="text" placeholder="Grand Canyon..." name="topic" label="Topic" onChange={setTopic} roundness="xl" defaultValue={topic} />
|
||||||
<button
|
<button
|
||||||
@@ -85,26 +186,29 @@ const PartTab = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{part && (
|
{part && (
|
||||||
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide">
|
<>
|
||||||
<div className="flex gap-4">
|
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide">
|
||||||
{part.exercises.map((x) => (
|
<div className="flex gap-4">
|
||||||
<span className="rounded-xl bg-white border border-ielts-listening p-1 px-4" key={x.id}>
|
{part.exercises.map((x) => (
|
||||||
{x.type && convertCamelCaseToReadable(x.type)}
|
<span className="rounded-xl bg-white border border-ielts-listening p-1 px-4" key={x.id}>
|
||||||
</span>
|
{x.type && convertCamelCaseToReadable(x.type)}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{typeof part.text === "string" && <span className="w-full h-96">{part.text.replaceAll("\n\n", " ")}</span>}
|
|
||||||
{typeof part.text !== "string" && (
|
|
||||||
<div className="w-full h-96 flex flex-col gap-2">
|
|
||||||
{part.text.conversation.map((x, index) => (
|
|
||||||
<span key={index} className="flex gap-1">
|
|
||||||
<span className="font-semibold">{x.name}:</span>
|
|
||||||
{x.text.replaceAll("\n\n", " ")}
|
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{typeof part.text === "string" && <span className="w-full h-96">{part.text.replaceAll("\n\n", " ")}</span>}
|
||||||
</div>
|
{typeof part.text !== "string" && (
|
||||||
|
<div className="w-full h-96 flex flex-col gap-2">
|
||||||
|
{part.text.conversation.map((x, index) => (
|
||||||
|
<span key={index} className="flex gap-1">
|
||||||
|
<span className="font-semibold">{x.name}:</span>
|
||||||
|
{x.text.replaceAll("\n\n", " ")}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{renderExercises()}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
);
|
);
|
||||||
@@ -132,7 +236,6 @@ const ListeningGeneration = () => {
|
|||||||
const [minTimer, setMinTimer] = useState(30);
|
const [minTimer, setMinTimer] = useState(30);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [resultingExam, setResultingExam] = useState<ListeningExam>();
|
const [resultingExam, setResultingExam] = useState<ListeningExam>();
|
||||||
const [types, setTypes] = useState<string[]>([]);
|
|
||||||
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -145,20 +248,11 @@ const ListeningGeneration = () => {
|
|||||||
setMinTimer(sum > 0 ? sum : 5);
|
setMinTimer(sum > 0 ? sum : 5);
|
||||||
}, [part1, part2, part3, part4]);
|
}, [part1, part2, part3, part4]);
|
||||||
|
|
||||||
const availableTypes = [
|
|
||||||
{type: "multipleChoice", label: "Multiple Choice"},
|
|
||||||
{type: "writeBlanksQuestions", label: "Write the Blanks: Questions"},
|
|
||||||
{type: "writeBlanksFill", label: "Write the Blanks: Fill"},
|
|
||||||
{type: "writeBlanksForm", label: "Write the Blanks: Form"},
|
|
||||||
];
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
const setExams = useExamStore((state) => state.setExams);
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||||
|
|
||||||
const toggleType = (type: string) => setTypes((prev) => (prev.includes(type) ? [...prev.filter((x) => x !== type)] : [...prev, type]));
|
|
||||||
|
|
||||||
const submitExam = () => {
|
const submitExam = () => {
|
||||||
const parts = [part1, part2, part3, part4].filter((x) => !!x);
|
const parts = [part1, part2, part3, part4].filter((x) => !!x);
|
||||||
console.log({parts});
|
console.log({parts});
|
||||||
@@ -167,11 +261,16 @@ const ListeningGeneration = () => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post(`/api/exam/listening/generate/listening`, {parts, minTimer, difficulty})
|
.post(`/api/exam/listening/generate/listening`, {
|
||||||
|
id: generate({minLength: 4, maxLength: 8, min: 3, max: 5, join: " ", formatter: capitalize}),
|
||||||
|
parts,
|
||||||
|
minTimer,
|
||||||
|
difficulty,
|
||||||
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
playSound("sent");
|
playSound("sent");
|
||||||
console.log(`Generated Exam ID: ${result.data.id}`);
|
console.log(`Generated Exam ID: ${result.data.id}`);
|
||||||
toast.success("This new exam has been generated successfully! Check the ID in our browser's console.");
|
toast.success(`Generated Exam ID: ${result.data.id}`);
|
||||||
setResultingExam(result.data);
|
setResultingExam(result.data);
|
||||||
|
|
||||||
setPart1(undefined);
|
setPart1(undefined);
|
||||||
@@ -179,7 +278,6 @@ const ListeningGeneration = () => {
|
|||||||
setPart3(undefined);
|
setPart3(undefined);
|
||||||
setPart4(undefined);
|
setPart4(undefined);
|
||||||
setDifficulty(sample(DIFFICULTIES)!);
|
setDifficulty(sample(DIFFICULTIES)!);
|
||||||
setTypes([]);
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
@@ -220,34 +318,16 @@ const ListeningGeneration = () => {
|
|||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||||
<Select
|
<Select
|
||||||
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
|
options={DIFFICULTIES.map((x) => ({
|
||||||
|
value: x,
|
||||||
|
label: capitalize(x),
|
||||||
|
}))}
|
||||||
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
||||||
value={{value: difficulty, label: capitalize(difficulty)}}
|
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||||
disabled={!!part1 || !!part2 || !!part3 || !!part4}
|
disabled={!!part1 || !!part2 || !!part3 || !!part4}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Exercises</label>
|
|
||||||
<div className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
|
|
||||||
{availableTypes.map((x) => (
|
|
||||||
<span
|
|
||||||
onClick={() => toggleType(x.type)}
|
|
||||||
key={x.type}
|
|
||||||
className={clsx(
|
|
||||||
"px-6 py-4 w-64 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
!types.includes(x.type)
|
|
||||||
? "bg-white border-mti-gray-platinum"
|
|
||||||
: "bg-ielts-listening/70 border-ielts-listening text-white",
|
|
||||||
)}>
|
|
||||||
{x.label}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-listening/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-listening/20 p-1">
|
||||||
<Tab
|
<Tab
|
||||||
@@ -297,12 +377,36 @@ const ListeningGeneration = () => {
|
|||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels>
|
<Tab.Panels>
|
||||||
{[
|
{[
|
||||||
{part: part1, setPart: setPart1},
|
{
|
||||||
{part: part2, setPart: setPart2},
|
part: part1,
|
||||||
{part: part3, setPart: setPart3},
|
setPart: setPart1,
|
||||||
{part: part4, setPart: setPart4},
|
types: [MULTIPLE_CHOICE, WRITE_BLANKS_QUESTIONS, WRITE_BLANKS_FILL, WRITE_BLANKS_FORM],
|
||||||
].map(({part, setPart}, index) => (
|
},
|
||||||
<PartTab part={part} difficulty={difficulty} types={types} index={index + 1} key={index} setPart={setPart} />
|
{
|
||||||
|
part: part2,
|
||||||
|
setPart: setPart2,
|
||||||
|
types: [MULTIPLE_CHOICE, WRITE_BLANKS_QUESTIONS],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
part: part3,
|
||||||
|
setPart: setPart3,
|
||||||
|
types: [MULTIPLE_CHOICE_3, WRITE_BLANKS_QUESTIONS],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
part: part4,
|
||||||
|
setPart: setPart4,
|
||||||
|
types: [MULTIPLE_CHOICE, WRITE_BLANKS_QUESTIONS, WRITE_BLANKS_FILL, WRITE_BLANKS_FORM],
|
||||||
|
},
|
||||||
|
].map(({part, setPart, types}, index) => (
|
||||||
|
<PartTab
|
||||||
|
part={part}
|
||||||
|
difficulty={difficulty}
|
||||||
|
availableTypes={types}
|
||||||
|
index={index + 1}
|
||||||
|
key={index}
|
||||||
|
setPart={setPart}
|
||||||
|
updatePart={setPart}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
|
|||||||
@@ -1,37 +1,53 @@
|
|||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
import {Difficulty, ReadingExam, ReadingPart} from "@/interfaces/exam";
|
import {Difficulty, Exercise, ReadingExam, ReadingPart} from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {getExamById} from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
import {playSound} from "@/utils/sound";
|
import {playSound} from "@/utils/sound";
|
||||||
import {convertCamelCaseToReadable} from "@/utils/string";
|
import {convertCamelCaseToReadable} from "@/utils/string";
|
||||||
|
import {generate} from "random-words";
|
||||||
import {Tab} from "@headlessui/react";
|
import {Tab} from "@headlessui/react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize, sample} from "lodash";
|
import {capitalize, sample} from "lodash";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState, Dispatch, SetStateAction} from "react";
|
||||||
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import {v4} from "uuid";
|
import {v4} from "uuid";
|
||||||
|
import FillBlanksEdit from "@/components/Generation/fill.blanks.edit";
|
||||||
|
import TrueFalseEdit from "@/components/Generation/true.false.edit";
|
||||||
|
import WriteBlanksEdit from "@/components/Generation/write.blanks.edit";
|
||||||
|
import MatchSentencesEdit from "@/components/Generation/match.sentences.edit";
|
||||||
|
import MultipleChoiceEdit from "@/components/Generation/multiple.choice.edit";
|
||||||
|
|
||||||
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||||
|
|
||||||
|
const availableTypes = [
|
||||||
|
{type: "fillBlanks", label: "Fill the Blanks"},
|
||||||
|
{type: "trueFalse", label: "True or False"},
|
||||||
|
{type: "writeBlanks", label: "Write the Blanks"},
|
||||||
|
{type: "paragraphMatch", label: "Match Sentences"},
|
||||||
|
];
|
||||||
|
|
||||||
const PartTab = ({
|
const PartTab = ({
|
||||||
part,
|
part,
|
||||||
types,
|
|
||||||
difficulty,
|
difficulty,
|
||||||
index,
|
index,
|
||||||
setPart,
|
setPart,
|
||||||
|
updatePart,
|
||||||
}: {
|
}: {
|
||||||
part?: ReadingPart;
|
part?: ReadingPart;
|
||||||
types: string[];
|
|
||||||
index: number;
|
index: number;
|
||||||
difficulty: Difficulty;
|
difficulty: Difficulty;
|
||||||
setPart: (part?: ReadingPart) => void;
|
setPart: (part?: ReadingPart) => void;
|
||||||
|
updatePart: Dispatch<SetStateAction<ReadingPart | undefined>>;
|
||||||
}) => {
|
}) => {
|
||||||
const [topic, setTopic] = useState("");
|
const [topic, setTopic] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [types, setTypes] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const toggleType = (type: string) => setTypes((prev) => (prev.includes(type) ? [...prev.filter((x) => x !== type)] : [...prev, type]));
|
||||||
|
|
||||||
const generate = () => {
|
const generate = () => {
|
||||||
const url = new URLSearchParams();
|
const url = new URLSearchParams();
|
||||||
@@ -45,6 +61,8 @@ const PartTab = ({
|
|||||||
axios
|
axios
|
||||||
.get(`/api/exam/reading/generate/reading_passage_${index}${topic || types ? `?${url.toString()}` : ""}`)
|
.get(`/api/exam/reading/generate/reading_passage_${index}${topic || types ? `?${url.toString()}` : ""}`)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
|
console.log(result.data);
|
||||||
|
|
||||||
playSound(typeof result.data === "string" ? "error" : "check");
|
playSound(typeof result.data === "string" ? "error" : "check");
|
||||||
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
||||||
setPart(result.data);
|
setPart(result.data);
|
||||||
@@ -56,8 +74,143 @@ const PartTab = ({
|
|||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderExercises = () => {
|
||||||
|
return part?.exercises.map((exercise) => {
|
||||||
|
switch (exercise.type) {
|
||||||
|
case "fillBlanks":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Exercise: Fill Blanks</h1>
|
||||||
|
<FillBlanksEdit
|
||||||
|
exercise={exercise}
|
||||||
|
key={exercise.id}
|
||||||
|
updateExercise={(data: any) =>
|
||||||
|
updatePart((part?: ReadingPart) => {
|
||||||
|
if (part) {
|
||||||
|
const exercises = part.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x)) as Exercise[];
|
||||||
|
const updatedPart = {...part, exercises} as ReadingPart;
|
||||||
|
return updatedPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
return part;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "trueFalse":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Exercise: True or False</h1>
|
||||||
|
<TrueFalseEdit
|
||||||
|
exercise={exercise}
|
||||||
|
key={exercise.id}
|
||||||
|
updateExercise={(data: any) => {
|
||||||
|
updatePart((part?: ReadingPart) => {
|
||||||
|
if (part) {
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
exercises: part.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x)),
|
||||||
|
} as ReadingPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "multipleChoice":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Exercise: True or False</h1>
|
||||||
|
<MultipleChoiceEdit
|
||||||
|
exercise={exercise}
|
||||||
|
key={exercise.id}
|
||||||
|
updateExercise={(data: any) => {
|
||||||
|
updatePart((part?: ReadingPart) => {
|
||||||
|
if (part) {
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
exercises: part.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x)),
|
||||||
|
} as ReadingPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "writeBlanks":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Exercise: Write Blanks</h1>
|
||||||
|
<WriteBlanksEdit
|
||||||
|
exercise={exercise}
|
||||||
|
key={exercise.id}
|
||||||
|
updateExercise={(data: any) => {
|
||||||
|
updatePart((part?: ReadingPart) => {
|
||||||
|
if (part) {
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
exercises: part.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x)),
|
||||||
|
} as ReadingPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "matchSentences":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Exercise: Match Sentences</h1>
|
||||||
|
<MatchSentencesEdit
|
||||||
|
exercise={exercise}
|
||||||
|
key={exercise.id}
|
||||||
|
updateExercise={(data: any) => {
|
||||||
|
updatePart((part?: ReadingPart) => {
|
||||||
|
if (part) {
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
exercises: part.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x)),
|
||||||
|
} as ReadingPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Panel className="w-full bg-ielts-reading/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-reading/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Exercises</label>
|
||||||
|
<div className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
|
||||||
|
{[...availableTypes, ...(index === 3 ? [{type: "ideaMatch", label: "Idea Match"}] : [])].map((x) => (
|
||||||
|
<span
|
||||||
|
onClick={() => toggleType(x.type)}
|
||||||
|
key={x.type}
|
||||||
|
className={clsx(
|
||||||
|
"px-6 py-4 w-64 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
!types.includes(x.type) ? "bg-white border-mti-gray-platinum" : "bg-ielts-reading/70 border-ielts-reading text-white",
|
||||||
|
)}>
|
||||||
|
{x.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex gap-4 items-end">
|
<div className="flex gap-4 items-end">
|
||||||
<Input type="text" placeholder="Grand Canyon..." name="topic" label="Topic" onChange={setTopic} roundness="xl" defaultValue={topic} />
|
<Input type="text" placeholder="Grand Canyon..." name="topic" label="Topic" onChange={setTopic} roundness="xl" defaultValue={topic} />
|
||||||
<button
|
<button
|
||||||
@@ -86,17 +239,20 @@ const PartTab = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{part && (
|
{part && (
|
||||||
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide">
|
<>
|
||||||
<div className="flex gap-4">
|
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide">
|
||||||
{part.exercises.map((x) => (
|
<div className="flex gap-4">
|
||||||
<span className="rounded-xl bg-white border border-ielts-reading p-1 px-4" key={x.id}>
|
{part.exercises.map((x) => (
|
||||||
{x.type && convertCamelCaseToReadable(x.type)}
|
<span className="rounded-xl bg-white border border-ielts-reading p-1 px-4" key={x.id}>
|
||||||
</span>
|
{x.type && convertCamelCaseToReadable(x.type)}
|
||||||
))}
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold">{part.text.title}</h3>
|
||||||
|
<span className="w-full h-96">{part.text.content}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold">{part.text.title}</h3>
|
{renderExercises()}
|
||||||
<span className="w-full h-96">{part.text.content}</span>
|
</>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
);
|
);
|
||||||
@@ -107,7 +263,6 @@ const ReadingGeneration = () => {
|
|||||||
const [part2, setPart2] = useState<ReadingPart>();
|
const [part2, setPart2] = useState<ReadingPart>();
|
||||||
const [part3, setPart3] = useState<ReadingPart>();
|
const [part3, setPart3] = useState<ReadingPart>();
|
||||||
const [minTimer, setMinTimer] = useState(60);
|
const [minTimer, setMinTimer] = useState(60);
|
||||||
const [types, setTypes] = useState<string[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [resultingExam, setResultingExam] = useState<ReadingExam>();
|
const [resultingExam, setResultingExam] = useState<ReadingExam>();
|
||||||
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||||
@@ -122,15 +277,6 @@ const ReadingGeneration = () => {
|
|||||||
const setExams = useExamStore((state) => state.setExams);
|
const setExams = useExamStore((state) => state.setExams);
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||||
|
|
||||||
const availableTypes = [
|
|
||||||
{type: "fillBlanks", label: "Fill the Blanks"},
|
|
||||||
{type: "trueFalse", label: "True or False"},
|
|
||||||
{type: "writeBlanks", label: "Write the Blanks"},
|
|
||||||
{type: "matchSentences", label: "Match Sentences"},
|
|
||||||
];
|
|
||||||
|
|
||||||
const toggleType = (type: string) => setTypes((prev) => (prev.includes(type) ? [...prev.filter((x) => x !== type)] : [...prev, type]));
|
|
||||||
|
|
||||||
const loadExam = async (examId: string) => {
|
const loadExam = async (examId: string) => {
|
||||||
const exam = await getExamById("reading", examId.trim());
|
const exam = await getExamById("reading", examId.trim());
|
||||||
if (!exam) {
|
if (!exam) {
|
||||||
@@ -160,7 +306,7 @@ const ReadingGeneration = () => {
|
|||||||
isDiagnostic: false,
|
isDiagnostic: false,
|
||||||
minTimer,
|
minTimer,
|
||||||
module: "reading",
|
module: "reading",
|
||||||
id: v4(),
|
id: generate({minLength: 4, maxLength: 8, min: 3, max: 5, join: " ", formatter: capitalize}),
|
||||||
type: "academic",
|
type: "academic",
|
||||||
variant: parts.length === 3 ? "full" : "partial",
|
variant: parts.length === 3 ? "full" : "partial",
|
||||||
difficulty,
|
difficulty,
|
||||||
@@ -171,7 +317,7 @@ const ReadingGeneration = () => {
|
|||||||
.then((result) => {
|
.then((result) => {
|
||||||
playSound("sent");
|
playSound("sent");
|
||||||
console.log(`Generated Exam ID: ${result.data.id}`);
|
console.log(`Generated Exam ID: ${result.data.id}`);
|
||||||
toast.success("This new exam has been generated successfully! Check the ID in our browser's console.");
|
toast.success(`Generated Exam ID: ${result.data.id}`);
|
||||||
setResultingExam(result.data);
|
setResultingExam(result.data);
|
||||||
|
|
||||||
setPart1(undefined);
|
setPart1(undefined);
|
||||||
@@ -179,7 +325,6 @@ const ReadingGeneration = () => {
|
|||||||
setPart3(undefined);
|
setPart3(undefined);
|
||||||
setDifficulty(sample(DIFFICULTIES)!);
|
setDifficulty(sample(DIFFICULTIES)!);
|
||||||
setMinTimer(60);
|
setMinTimer(60);
|
||||||
setTypes([]);
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
@@ -204,32 +349,16 @@ const ReadingGeneration = () => {
|
|||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||||
<Select
|
<Select
|
||||||
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
|
options={DIFFICULTIES.map((x) => ({
|
||||||
|
value: x,
|
||||||
|
label: capitalize(x),
|
||||||
|
}))}
|
||||||
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
||||||
value={{value: difficulty, label: capitalize(difficulty)}}
|
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||||
disabled={!!part1 || !!part2 || !!part3}
|
disabled={!!part1 || !!part2 || !!part3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Exercises</label>
|
|
||||||
<div className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
|
|
||||||
{availableTypes.map((x) => (
|
|
||||||
<span
|
|
||||||
onClick={() => toggleType(x.type)}
|
|
||||||
key={x.type}
|
|
||||||
className={clsx(
|
|
||||||
"px-6 py-4 w-64 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
!types.includes(x.type) ? "bg-white border-mti-gray-platinum" : "bg-ielts-reading/70 border-ielts-reading text-white",
|
|
||||||
)}>
|
|
||||||
{x.label}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-reading/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-reading/20 p-1">
|
||||||
<Tab
|
<Tab
|
||||||
@@ -272,7 +401,7 @@ const ReadingGeneration = () => {
|
|||||||
{part: part2, setPart: setPart2},
|
{part: part2, setPart: setPart2},
|
||||||
{part: part3, setPart: setPart3},
|
{part: part3, setPart: setPart3},
|
||||||
].map(({part, setPart}, index) => (
|
].map(({part, setPart}, index) => (
|
||||||
<PartTab part={part} types={types} difficulty={difficulty} index={index + 1} key={index} setPart={setPart} />
|
<PartTab part={part} difficulty={difficulty} index={index + 1} key={index} setPart={setPart} updatePart={setPart} />
|
||||||
))}
|
))}
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import clsx from "clsx";
|
|||||||
import {capitalize, sample, uniq} from "lodash";
|
import {capitalize, sample, uniq} from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useEffect, useState} from "react";
|
import {generate} from "random-words";
|
||||||
|
import {useEffect, useState, Dispatch, SetStateAction} from "react";
|
||||||
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import {v4} from "uuid";
|
import {v4} from "uuid";
|
||||||
@@ -24,11 +25,13 @@ const PartTab = ({
|
|||||||
index,
|
index,
|
||||||
difficulty,
|
difficulty,
|
||||||
setPart,
|
setPart,
|
||||||
|
updatePart,
|
||||||
}: {
|
}: {
|
||||||
part?: SpeakingPart;
|
part?: SpeakingPart;
|
||||||
difficulty: Difficulty;
|
difficulty: Difficulty;
|
||||||
index: number;
|
index: number;
|
||||||
setPart: (part?: SpeakingPart) => void;
|
setPart: (part?: SpeakingPart) => void;
|
||||||
|
updatePart: Dispatch<SetStateAction<SpeakingPart | undefined>>;
|
||||||
}) => {
|
}) => {
|
||||||
const [gender, setGender] = useState<"male" | "female">("male");
|
const [gender, setGender] = useState<"male" | "female">("male");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -45,6 +48,7 @@ const PartTab = ({
|
|||||||
.then((result) => {
|
.then((result) => {
|
||||||
playSound(typeof result.data === "string" ? "error" : "check");
|
playSound(typeof result.data === "string" ? "error" : "check");
|
||||||
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
if (typeof result.data === "string") return toast.error("Something went wrong, please try to generate again.");
|
||||||
|
console.log(result.data);
|
||||||
setPart(result.data);
|
setPart(result.data);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -54,7 +58,7 @@ const PartTab = ({
|
|||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateVideo = () => {
|
const generateVideo = async () => {
|
||||||
if (!part) return toast.error("Please generate the first part before generating the video!");
|
if (!part) return toast.error("Please generate the first part before generating the video!");
|
||||||
toast.info("This will take quite a while, please do not leave this page or close the tab/window.");
|
toast.info("This will take quite a while, please do not leave this page or close the tab/window.");
|
||||||
|
|
||||||
@@ -64,13 +68,22 @@ const PartTab = ({
|
|||||||
const initialTime = moment();
|
const initialTime = moment();
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post(`/api/exam/speaking/generate/speaking/generate_${index === 3 ? "interactive" : "speaking"}_video`, {...part, avatar: avatar?.id})
|
.post(`/api/exam/speaking/generate/speaking/generate_video_${index}`, {
|
||||||
|
...part,
|
||||||
|
avatar: avatar?.id,
|
||||||
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
const isError = typeof result.data === "string" || moment().diff(initialTime, "seconds") < 60;
|
const isError = typeof result.data === "string" || moment().diff(initialTime, "seconds") < 60;
|
||||||
|
|
||||||
playSound(isError ? "error" : "check");
|
playSound(isError ? "error" : "check");
|
||||||
|
console.log(result.data);
|
||||||
if (isError) return toast.error("Something went wrong, please try to generate the video again.");
|
if (isError) return toast.error("Something went wrong, please try to generate the video again.");
|
||||||
setPart({...part, result: {...result.data, topic: part?.topic}, gender, avatar});
|
setPart({
|
||||||
|
...part,
|
||||||
|
result: {...result.data, topic: part?.topic},
|
||||||
|
gender,
|
||||||
|
avatar,
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast.error("Something went wrong!");
|
toast.error("Something went wrong!");
|
||||||
@@ -139,7 +152,9 @@ const PartTab = ({
|
|||||||
)}
|
)}
|
||||||
{part && !isLoading && (
|
{part && !isLoading && (
|
||||||
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide h-96">
|
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide h-96">
|
||||||
<h3 className="text-xl font-semibold">{part.topic}</h3>
|
<h3 className="text-xl font-semibold">
|
||||||
|
{!!part.first_topic && !!part.second_topic ? `${part.first_topic} & ${part.second_topic}` : part.topic}
|
||||||
|
</h3>
|
||||||
{part.question && <span className="w-full">{part.question}</span>}
|
{part.question && <span className="w-full">{part.question}</span>}
|
||||||
{part.questions && (
|
{part.questions && (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -166,6 +181,28 @@ const PartTab = ({
|
|||||||
<b>Instructor:</b> {part.avatar.name} - {capitalize(part.avatar.gender)}
|
<b>Instructor:</b> {part.avatar.name} - {capitalize(part.avatar.gender)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{part.questions?.map((question, index) => (
|
||||||
|
<Input
|
||||||
|
key={index}
|
||||||
|
type="text"
|
||||||
|
label="Question"
|
||||||
|
name="question"
|
||||||
|
required
|
||||||
|
value={question}
|
||||||
|
onChange={(value) =>
|
||||||
|
updatePart((part?: SpeakingPart) => {
|
||||||
|
if (part) {
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
questions: part.questions?.map((x, xIndex) => (xIndex === index ? value : x)),
|
||||||
|
} as SpeakingPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
return part;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
@@ -177,6 +214,8 @@ interface SpeakingPart {
|
|||||||
question?: string;
|
question?: string;
|
||||||
questions?: string[];
|
questions?: string[];
|
||||||
topic: string;
|
topic: string;
|
||||||
|
first_topic?: string;
|
||||||
|
second_topic?: string;
|
||||||
result?: SpeakingExercise | InteractiveSpeakingExercise;
|
result?: SpeakingExercise | InteractiveSpeakingExercise;
|
||||||
gender?: "male" | "female";
|
gender?: "male" | "female";
|
||||||
avatar?: (typeof AVATARS)[number];
|
avatar?: (typeof AVATARS)[number];
|
||||||
@@ -208,10 +247,18 @@ const SpeakingGeneration = () => {
|
|||||||
|
|
||||||
const genders = [part1?.gender, part2?.gender, part3?.gender].filter((x) => !!x);
|
const genders = [part1?.gender, part2?.gender, part3?.gender].filter((x) => !!x);
|
||||||
|
|
||||||
|
const exercises = [part1?.result, part2?.result, part3?.result]
|
||||||
|
.filter((x) => !!x)
|
||||||
|
.map((x) => ({
|
||||||
|
...x,
|
||||||
|
first_title: x?.type === "interactiveSpeaking" ? x.first_topic : undefined,
|
||||||
|
second_title: x?.type === "interactiveSpeaking" ? x.second_topic : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
const exam: SpeakingExam = {
|
const exam: SpeakingExam = {
|
||||||
id: v4(),
|
id: generate({minLength: 4, maxLength: 8, min: 3, max: 5, join: " ", formatter: capitalize}),
|
||||||
isDiagnostic: false,
|
isDiagnostic: false,
|
||||||
exercises: [part1?.result, part2?.result, part3?.result].filter((x) => !!x) as (SpeakingExercise | InteractiveSpeakingExercise)[],
|
exercises: exercises as (SpeakingExercise | InteractiveSpeakingExercise)[],
|
||||||
minTimer,
|
minTimer,
|
||||||
variant: minTimer >= 14 ? "full" : "partial",
|
variant: minTimer >= 14 ? "full" : "partial",
|
||||||
module: "speaking",
|
module: "speaking",
|
||||||
@@ -223,7 +270,7 @@ const SpeakingGeneration = () => {
|
|||||||
.then((result) => {
|
.then((result) => {
|
||||||
playSound("sent");
|
playSound("sent");
|
||||||
console.log(`Generated Exam ID: ${result.data.id}`);
|
console.log(`Generated Exam ID: ${result.data.id}`);
|
||||||
toast.success("This new exam has been generated successfully! Check the ID in our browser's console.");
|
toast.success(`Generated Exam ID: ${result.data.id}`);
|
||||||
setResultingExam(result.data);
|
setResultingExam(result.data);
|
||||||
|
|
||||||
setPart1(undefined);
|
setPart1(undefined);
|
||||||
@@ -271,7 +318,10 @@ const SpeakingGeneration = () => {
|
|||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||||
<Select
|
<Select
|
||||||
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
|
options={DIFFICULTIES.map((x) => ({
|
||||||
|
value: x,
|
||||||
|
label: capitalize(x),
|
||||||
|
}))}
|
||||||
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
||||||
value={{value: difficulty, label: capitalize(difficulty)}}
|
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||||
disabled={!!part1 || !!part2 || !!part3}
|
disabled={!!part1 || !!part2 || !!part3}
|
||||||
@@ -321,7 +371,7 @@ const SpeakingGeneration = () => {
|
|||||||
{part: part2, setPart: setPart2},
|
{part: part2, setPart: setPart2},
|
||||||
{part: part3, setPart: setPart3},
|
{part: part3, setPart: setPart3},
|
||||||
].map(({part, setPart}, index) => (
|
].map(({part, setPart}, index) => (
|
||||||
<PartTab difficulty={difficulty} part={part} index={index + 1} key={index} setPart={setPart} />
|
<PartTab difficulty={difficulty} part={part} index={index + 1} key={index} setPart={setPart} updatePart={setPart} />
|
||||||
))}
|
))}
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import axios from "axios";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize, sample} from "lodash";
|
import {capitalize, sample} from "lodash";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
|
import {generate} from "random-words";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
@@ -151,7 +152,7 @@ const WritingGeneration = () => {
|
|||||||
minTimer,
|
minTimer,
|
||||||
module: "writing",
|
module: "writing",
|
||||||
exercises: [...(exercise1 ? [exercise1] : []), ...(exercise2 ? [exercise2] : [])],
|
exercises: [...(exercise1 ? [exercise1] : []), ...(exercise2 ? [exercise2] : [])],
|
||||||
id: v4(),
|
id: generate({minLength: 4, maxLength: 8, min: 3, max: 5, join: " ", formatter: capitalize}),
|
||||||
variant: exercise1 && exercise2 ? "full" : "partial",
|
variant: exercise1 && exercise2 ? "full" : "partial",
|
||||||
difficulty,
|
difficulty,
|
||||||
};
|
};
|
||||||
@@ -160,6 +161,7 @@ const WritingGeneration = () => {
|
|||||||
.post(`/api/exam/writing`, exam)
|
.post(`/api/exam/writing`, exam)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
console.log(`Generated Exam ID: ${result.data.id}`);
|
console.log(`Generated Exam ID: ${result.data.id}`);
|
||||||
|
toast.success(`Generated Exam ID: ${result.data.id}`);
|
||||||
playSound("sent");
|
playSound("sent");
|
||||||
toast.success("This new exam has been generated successfully! Check the ID in our browser's console.");
|
toast.success("This new exam has been generated successfully! Check the ID in our browser's console.");
|
||||||
setResultingExam(result.data);
|
setResultingExam(result.data);
|
||||||
|
|||||||
@@ -19,11 +19,10 @@ import moment from "moment";
|
|||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
hasExpired?: boolean;
|
hasExpired?: boolean;
|
||||||
clientID: string;
|
|
||||||
reload: () => void;
|
reload: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PaymentDue({user, hasExpired = false, clientID, reload}: Props) {
|
export default function PaymentDue({user, hasExpired = false, reload}: Props) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [appliedDiscount, setAppliedDiscount] = useState(0);
|
const [appliedDiscount, setAppliedDiscount] = useState(0);
|
||||||
|
|
||||||
@@ -137,7 +136,6 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<PaymobPayment
|
<PaymobPayment
|
||||||
key={clientID}
|
|
||||||
user={user}
|
user={user}
|
||||||
setIsPaymentLoading={setIsLoading}
|
setIsPaymentLoading={setIsLoading}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
@@ -177,7 +175,6 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
|||||||
{user.corporateInformation.payment.value} {user.corporateInformation.payment.currency}
|
{user.corporateInformation.payment.value} {user.corporateInformation.payment.currency}
|
||||||
</span>
|
</span>
|
||||||
<PaymobPayment
|
<PaymobPayment
|
||||||
key={clientID}
|
|
||||||
user={user}
|
user={user}
|
||||||
setIsPaymentLoading={setIsLoading}
|
setIsPaymentLoading={setIsLoading}
|
||||||
currency={user.corporateInformation.payment.currency}
|
currency={user.corporateInformation.payment.currency}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {app} from "@/firebase";
|
|||||||
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc, deleteDoc} from "firebase/firestore";
|
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc, deleteDoc} from "firebase/firestore";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {uuidv4} from "@firebase/util";
|
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ const generateExams = async (
|
|||||||
|
|
||||||
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {
|
const {
|
||||||
|
examIDs,
|
||||||
selectedModules,
|
selectedModules,
|
||||||
assignees,
|
assignees,
|
||||||
// Generate multiple true would generate an unique exam for each user
|
// Generate multiple true would generate an unique exam for each user
|
||||||
@@ -111,6 +112,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
instructorGender,
|
instructorGender,
|
||||||
...body
|
...body
|
||||||
} = req.body as {
|
} = req.body as {
|
||||||
|
examIDs?: {id: string; module: Module}[];
|
||||||
selectedModules: Module[];
|
selectedModules: Module[];
|
||||||
assignees: string[];
|
assignees: string[];
|
||||||
generateMultiple: Boolean;
|
generateMultiple: Boolean;
|
||||||
@@ -121,7 +123,9 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
instructorGender?: InstructorGender;
|
instructorGender?: InstructorGender;
|
||||||
};
|
};
|
||||||
|
|
||||||
const exams: ExamWithUser[] = await generateExams(generateMultiple, selectedModules, assignees, variant, instructorGender);
|
const exams: ExamWithUser[] = !!examIDs
|
||||||
|
? examIDs.flatMap((e) => assignees.map((a) => ({...e, assignee: a})))
|
||||||
|
: await generateExams(generateMultiple, selectedModules, assignees, variant, instructorGender);
|
||||||
|
|
||||||
if (exams.length === 0) {
|
if (exams.length === 0) {
|
||||||
res.status(400).json({ok: false, error: "No exams found for the selected modules"});
|
res.status(400).json({ok: false, error: "No exams found for the selected modules"});
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
res.status(200).json(null);
|
res.status(200).json(null);
|
||||||
|
|
||||||
console.log("🌱 - Still processing");
|
console.log("🌱 - Still processing");
|
||||||
const backendRequest = await evaluate({answers: uploadingAudios});
|
const backendRequest = await evaluate({answers: uploadingAudios}, fields.variant);
|
||||||
console.log("🌱 - Process complete");
|
console.log("🌱 - Process complete");
|
||||||
|
|
||||||
const correspondingStat = await getCorrespondingStat(fields.id, 1);
|
const correspondingStat = await getCorrespondingStat(fields.id, 1);
|
||||||
@@ -79,8 +79,8 @@ async function getCorrespondingStat(id: string, index: number): Promise<Stat> {
|
|||||||
return getCorrespondingStat(id, index + 1);
|
return getCorrespondingStat(id, index + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function evaluate(body: {answers: object[]}): Promise<AxiosResponse> {
|
async function evaluate(body: {answers: object[]}, variant?: "initial" | "final"): Promise<AxiosResponse> {
|
||||||
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/speaking_task_3`, body, {
|
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/speaking_task_${variant === "initial" ? "1" : "3"}`, body, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ async function getCorrespondingStat(id: string, index: number): Promise<Stat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function evaluate(body: {answer: string; question: string}, task: number): Promise<AxiosResponse> {
|
async function evaluate(body: {answer: string; question: string}, task: number): Promise<AxiosResponse> {
|
||||||
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/speaking_task_${task}`, body, {
|
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/speaking_task_2`, body, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) return res.status(401).json({ok: false});
|
if (!req.session.user) return res.status(401).json({ok: false});
|
||||||
if (req.session.user.type !== "developer") return res.status(403).json({ok: false});
|
|
||||||
|
|
||||||
const {endpoint, topic, exercises, difficulty} = req.query as {
|
const {endpoint, topic, exercises, difficulty} = req.query as {
|
||||||
module: Module;
|
module: Module;
|
||||||
@@ -48,7 +47,6 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) return res.status(401).json({ok: false});
|
if (!req.session.user) return res.status(401).json({ok: false});
|
||||||
if (req.session.user.type !== "developer") return res.status(403).json({ok: false});
|
|
||||||
|
|
||||||
const {endpoint, topic, exercises} = req.query as {module: Module; endpoint: string[]; topic?: string; exercises?: string[]};
|
const {endpoint, topic, exercises} = req.query as {module: Module; endpoint: string[]; topic?: string; exercises?: string[]};
|
||||||
const url = `${process.env.BACKEND_URL}/${endpoint.join("/")}`;
|
const url = `${process.env.BACKEND_URL}/${endpoint.join("/")}`;
|
||||||
|
|||||||
35
src/pages/api/exam/[module]/generate/level.ts
Normal file
35
src/pages/api/exam/[module]/generate/level.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
|
import {app} from "@/firebase";
|
||||||
|
import {getFirestore, collection, getDocs, query, where} from "firebase/firestore";
|
||||||
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
import {shuffle} from "lodash";
|
||||||
|
import {Difficulty, Exam} from "@/interfaces/exam";
|
||||||
|
import {Stat} from "@/interfaces/user";
|
||||||
|
import {Module} from "@/interfaces";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "POST") return post(req, res);
|
||||||
|
|
||||||
|
return res.status(404).json({ok: false});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) return res.status(401).json({ok: false});
|
||||||
|
|
||||||
|
const body = req.body;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
Object.keys(body).forEach((key) => params.append(key, body[key]));
|
||||||
|
const result = await axios.get(`${process.env.BACKEND_URL}/custom_level?${params.toString()}`, {
|
||||||
|
headers: {Authorization: `Bearer ${process.env.BACKEND_JWT}`},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json(result.data);
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
const {module} = req.query as {module: string};
|
const {module} = req.query as {module: string};
|
||||||
|
|
||||||
const exam = {...req.body, module: module};
|
const exam = {...req.body, module: module, createdBy: req.session.user.id, createdAt: new Date().toISOString()};
|
||||||
await setDoc(doc(db, module, req.body.id), exam);
|
await setDoc(doc(db, module, req.body.id), exam);
|
||||||
|
|
||||||
res.status(200).json(exam);
|
res.status(200).json(exam);
|
||||||
|
|||||||
@@ -30,29 +30,74 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
if (req.method === "POST") await post(req, res);
|
if (req.method === "POST") await post(req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getGroupsForUser = async (admin: string, participant: string) => {
|
||||||
|
try {
|
||||||
|
const queryConstraints = [
|
||||||
|
...(admin ? [where("admin", "==", admin)] : []),
|
||||||
|
...(participant
|
||||||
|
? [where("participants", "array-contains", participant)]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
const snapshot = await getDocs(
|
||||||
|
queryConstraints.length > 0
|
||||||
|
? query(collection(db, "groups"), ...queryConstraints)
|
||||||
|
: collection(db, "groups")
|
||||||
|
);
|
||||||
|
const groups = snapshot.docs.map((doc) => ({
|
||||||
|
id: doc.id,
|
||||||
|
...doc.data(),
|
||||||
|
})) as Group[];
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const { admin, participant } = req.query as {
|
const { admin, participant } = req.query as {
|
||||||
admin: string;
|
admin: string;
|
||||||
participant: string;
|
participant: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryConstraints = [
|
if (req.session?.user?.type === "mastercorporate") {
|
||||||
...(admin ? [where("admin", "==", admin)] : []),
|
try {
|
||||||
...(participant
|
const masterCorporateGroups = await getGroupsForUser(admin, participant);
|
||||||
? [where("participants", "array-contains", participant)]
|
const corporatesFromMaster = masterCorporateGroups
|
||||||
: []),
|
.filter((g) => g.name === "Corporate")
|
||||||
];
|
.flatMap((g) => g.participants);
|
||||||
const snapshot = await getDocs(
|
|
||||||
queryConstraints.length > 0
|
|
||||||
? query(collection(db, "groups"), ...queryConstraints)
|
|
||||||
: collection(db, "groups"),
|
|
||||||
);
|
|
||||||
const groups = snapshot.docs.map((doc) => ({
|
|
||||||
id: doc.id,
|
|
||||||
...doc.data(),
|
|
||||||
})) as Group[];
|
|
||||||
|
|
||||||
res.status(200).json(groups);
|
if (corporatesFromMaster.length === 0) {
|
||||||
|
res.status(200).json([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Promise.all(
|
||||||
|
corporatesFromMaster.map((c) => getGroupsForUser(c, participant))
|
||||||
|
)
|
||||||
|
.then((groups) => {
|
||||||
|
res.status(200).json([...masterCorporateGroups, ...groups.flat()]);
|
||||||
|
return;
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ ok: false });
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const groups = await getGroupsForUser(admin, participant);
|
||||||
|
res.status(200).json(groups);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ ok: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
@@ -60,8 +105,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
body.participants.map(
|
body.participants.map(
|
||||||
async (p) => await updateExpiryDateOnGroup(p, body.admin),
|
async (p) => await updateExpiryDateOnGroup(p, body.admin)
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
await setDoc(doc(db, "groups", v4()), {
|
await setDoc(doc(db, "groups", v4()), {
|
||||||
|
|||||||
152
src/pages/api/make_user.ts
Normal file
152
src/pages/api/make_user.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { app } from "@/firebase";
|
||||||
|
import {
|
||||||
|
getFirestore,
|
||||||
|
setDoc,
|
||||||
|
doc,
|
||||||
|
query,
|
||||||
|
collection,
|
||||||
|
where,
|
||||||
|
getDocs,
|
||||||
|
getDoc,
|
||||||
|
deleteDoc,
|
||||||
|
limit,
|
||||||
|
updateDoc,
|
||||||
|
} from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import {v4} from "uuid";
|
||||||
|
import {Group} from "@/interfaces/user";
|
||||||
|
import {createUserWithEmailAndPassword, getAuth} from "firebase/auth";
|
||||||
|
|
||||||
|
const DEFAULT_DESIRED_LEVELS = {
|
||||||
|
reading: 9,
|
||||||
|
listening: 9,
|
||||||
|
writing: 9,
|
||||||
|
speaking: 9,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_LEVELS = {
|
||||||
|
reading: 0,
|
||||||
|
listening: 0,
|
||||||
|
writing: 0,
|
||||||
|
speaking: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const auth = getAuth(app);
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
|
||||||
|
if (req.method === "POST") return post(req, res);
|
||||||
|
|
||||||
|
return res.status(404).json({ ok: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const maker = req.session.user;
|
||||||
|
if (!maker) {
|
||||||
|
return res
|
||||||
|
.status(401)
|
||||||
|
.json({ ok: false, reason: "You must be logged in to make user!" });
|
||||||
|
}
|
||||||
|
const { email, passport_id, type, groupName } = req.body as {
|
||||||
|
email: string;
|
||||||
|
passport_id: string;
|
||||||
|
type: string,
|
||||||
|
groupName: string
|
||||||
|
};
|
||||||
|
// cleaning data
|
||||||
|
delete req.body.passport_id;
|
||||||
|
delete req.body.groupName;
|
||||||
|
|
||||||
|
await createUserWithEmailAndPassword(auth, email.toLowerCase(), passport_id)
|
||||||
|
.then(async (userCredentials) => {
|
||||||
|
const userId = userCredentials.user.uid;
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
...req.body,
|
||||||
|
bio: "",
|
||||||
|
type: type,
|
||||||
|
focus: "academic",
|
||||||
|
status: "paymentDue",
|
||||||
|
desiredLevels: DEFAULT_DESIRED_LEVELS,
|
||||||
|
levels: DEFAULT_LEVELS,
|
||||||
|
isFirstLogin: false,
|
||||||
|
isVerified: true
|
||||||
|
};
|
||||||
|
await setDoc(doc(db, "users", userId), user);
|
||||||
|
if (type === "corporate") {
|
||||||
|
const defaultTeachersGroup: Group = {
|
||||||
|
admin: userId,
|
||||||
|
id: v4(),
|
||||||
|
name: "Teachers",
|
||||||
|
participants: [],
|
||||||
|
disableEditing: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultStudentsGroup: Group = {
|
||||||
|
admin: userId,
|
||||||
|
id: v4(),
|
||||||
|
name: "Students",
|
||||||
|
participants: [],
|
||||||
|
disableEditing: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultCorporateGroup: Group = {
|
||||||
|
admin: userId,
|
||||||
|
id: v4(),
|
||||||
|
name: "Corporate",
|
||||||
|
participants: [],
|
||||||
|
disableEditing: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
await setDoc(doc(db, "groups", defaultTeachersGroup.id), defaultTeachersGroup);
|
||||||
|
await setDoc(doc(db, "groups", defaultStudentsGroup.id), defaultStudentsGroup);
|
||||||
|
await setDoc(doc(db, "groups", defaultCorporateGroup.id), defaultCorporateGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(typeof groupName === 'string' && groupName.trim().length > 0){
|
||||||
|
|
||||||
|
const q = query(collection(db, "groups"), where("admin", "==", maker.id), where("name", "==", groupName.trim()), limit(1))
|
||||||
|
const snapshot = await getDocs(q)
|
||||||
|
|
||||||
|
if(snapshot.empty){
|
||||||
|
const values = {
|
||||||
|
id: v4(),
|
||||||
|
admin: maker.id,
|
||||||
|
name: groupName.trim(),
|
||||||
|
participants: [userId],
|
||||||
|
disableEditing: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
await setDoc(doc(db, "groups", values.id) , values)
|
||||||
|
|
||||||
|
|
||||||
|
}else{
|
||||||
|
|
||||||
|
|
||||||
|
const doc = snapshot.docs[0]
|
||||||
|
const participants : string[] = doc.get('participants');
|
||||||
|
|
||||||
|
|
||||||
|
if(!participants.includes(userId)){
|
||||||
|
|
||||||
|
|
||||||
|
updateDoc(doc.ref, {
|
||||||
|
participants: [...participants, userId]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.log(error);
|
||||||
|
return res.status(401).json({error});
|
||||||
|
});
|
||||||
|
return res.status(200).json({ ok: true });
|
||||||
|
|
||||||
|
}
|
||||||
@@ -41,7 +41,12 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const response = await axios.post<IntentionResult>(
|
const response = await axios.post<IntentionResult>(
|
||||||
"https://oman.paymob.com/v1/intention/",
|
"https://oman.paymob.com/v1/intention/",
|
||||||
{...intention, payment_methods: [parseInt(process.env.PAYMOB_INTEGRATION_ID || "0")], items: []},
|
{
|
||||||
|
...intention,
|
||||||
|
payment_methods: [parseInt(process.env.PAYMOB_INTEGRATION_ID || "0")],
|
||||||
|
items: [],
|
||||||
|
extras: {...intention.extras, userID: req.session.user!.id},
|
||||||
|
} as PaymentIntention,
|
||||||
{headers: {Authorization: `Token ${process.env.PAYMOB_SECRET_KEY}`}},
|
{headers: {Authorization: `Token ${process.env.PAYMOB_SECRET_KEY}`}},
|
||||||
);
|
);
|
||||||
const intentionResult = response.data;
|
const intentionResult = response.data;
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const transactionResult = req.body as TransactionResult;
|
const transactionResult = req.body as TransactionResult;
|
||||||
const authToken = await authenticatePaymob();
|
const authToken = await authenticatePaymob();
|
||||||
|
|
||||||
|
console.log("WEBHOOK: ", transactionResult);
|
||||||
if (!checkTransaction(authToken, transactionResult.transaction.order.id)) return res.status(404).json({ok: false});
|
if (!checkTransaction(authToken, transactionResult.transaction.order.id)) return res.status(404).json({ok: false});
|
||||||
if (!transactionResult.transaction.success) return res.status(200).json({ok: false});
|
if (!transactionResult.transaction.success) return res.status(400).json({ok: false});
|
||||||
|
|
||||||
const {userID, duration, duration_unit} = transactionResult.intention.extras.creation_extras as {
|
const {userID, duration, duration_unit} = transactionResult.intention.extras.creation_extras as {
|
||||||
userID: string;
|
userID: string;
|
||||||
@@ -42,9 +43,9 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const initialDate = moment(subscriptionExpirationDate).isAfter(moment()) ? moment(subscriptionExpirationDate) : moment();
|
const initialDate = moment(subscriptionExpirationDate).isAfter(moment()) ? moment(subscriptionExpirationDate) : moment();
|
||||||
|
|
||||||
const updatedSubscriptionExpirationDate = moment(initialDate).add(duration, duration_unit).toISOString();
|
const updatedSubscriptionExpirationDate = moment(initialDate).add(duration, duration_unit).endOf("day").subtract(2, "hours").toISOString();
|
||||||
|
|
||||||
await setDoc(userSnapshot.ref, {subscriptionExpirationDate: updatedSubscriptionExpirationDate}, {merge: true});
|
await setDoc(userSnapshot.ref, {subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"}, {merge: true});
|
||||||
await setDoc(doc(db, "paypalpayments", v4()), {
|
await setDoc(doc(db, "paypalpayments", v4()), {
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
currency: transactionResult.transaction.currency,
|
currency: transactionResult.transaction.currency,
|
||||||
@@ -64,10 +65,16 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const participants = (await Promise.all(
|
const participants = (await Promise.all(
|
||||||
groups.flatMap((x) => x.participants).map(async (x) => ({...(await getDoc(doc(db, "users", x))).data(), id: x})),
|
groups.flatMap((x) => x.participants).map(async (x) => ({...(await getDoc(doc(db, "users", x))).data(), id: x})),
|
||||||
)) as User[];
|
)) as User[];
|
||||||
const sameExpiryDateParticipants = participants.filter((x) => x.subscriptionExpirationDate === subscriptionExpirationDate);
|
const sameExpiryDateParticipants = participants.filter(
|
||||||
|
(x) => x.subscriptionExpirationDate === subscriptionExpirationDate && x.status !== "disabled",
|
||||||
|
);
|
||||||
|
|
||||||
for (const participant of sameExpiryDateParticipants) {
|
for (const participant of sameExpiryDateParticipants) {
|
||||||
await setDoc(doc(db, "users", participant.id), {subscriptionExpirationDate: updatedSubscriptionExpirationDate}, {merge: true});
|
await setDoc(
|
||||||
|
doc(db, "users", participant.id),
|
||||||
|
{subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"},
|
||||||
|
{merge: true},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
30
src/pages/api/permissions/[id].ts
Normal file
30
src/pages/api/permissions/[id].ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { app } from "@/firebase";
|
||||||
|
import { getFirestore, doc, setDoc } from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "PATCH") return patch(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patch(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = req.query as { id: string };
|
||||||
|
const { users } = req.body;
|
||||||
|
try {
|
||||||
|
await setDoc(doc(db, "permissions", id), { users }, { merge: true });
|
||||||
|
return res.status(200).json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return res.status(500).json({ ok: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/pages/api/permissions/bootstrap.ts
Normal file
43
src/pages/api/permissions/bootstrap.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { app } from "@/firebase";
|
||||||
|
import {
|
||||||
|
getFirestore,
|
||||||
|
collection,
|
||||||
|
getDocs,
|
||||||
|
query,
|
||||||
|
where,
|
||||||
|
doc,
|
||||||
|
setDoc,
|
||||||
|
addDoc,
|
||||||
|
getDoc,
|
||||||
|
deleteDoc,
|
||||||
|
} from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import { Permission } from "@/interfaces/permissions";
|
||||||
|
import { bootstrap } from "@/utils/permissions.be";
|
||||||
|
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "GET") return get(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Boostrap");
|
||||||
|
try {
|
||||||
|
await bootstrap();
|
||||||
|
return res.status(200).json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update permissions", err);
|
||||||
|
return res.status(500).json({ ok: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/pages/api/permissions/index.ts
Normal file
36
src/pages/api/permissions/index.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { app } from "@/firebase";
|
||||||
|
import {
|
||||||
|
getFirestore,
|
||||||
|
collection,
|
||||||
|
getDocs,
|
||||||
|
query,
|
||||||
|
where,
|
||||||
|
doc,
|
||||||
|
setDoc,
|
||||||
|
addDoc,
|
||||||
|
getDoc,
|
||||||
|
deleteDoc,
|
||||||
|
} from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import { Permission } from "@/interfaces/permissions";
|
||||||
|
import { getPermissions, getPermissionDocs } from "@/utils/permissions.be";
|
||||||
|
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "GET") return get(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const docs = await getPermissionDocs();
|
||||||
|
res.status(200).json(docs);
|
||||||
|
}
|
||||||
@@ -143,9 +143,18 @@ async function registerCorporate(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
disableEditing: true,
|
disableEditing: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultCorporateGroup: Group = {
|
||||||
|
admin: userId,
|
||||||
|
id: v4(),
|
||||||
|
name: "Corporate",
|
||||||
|
participants: [],
|
||||||
|
disableEditing: true,
|
||||||
|
};
|
||||||
|
|
||||||
await setDoc(doc(db, "users", userId), user);
|
await setDoc(doc(db, "users", userId), user);
|
||||||
await setDoc(doc(db, "groups", defaultTeachersGroup.id), defaultTeachersGroup);
|
await setDoc(doc(db, "groups", defaultTeachersGroup.id), defaultTeachersGroup);
|
||||||
await setDoc(doc(db, "groups", defaultStudentsGroup.id), defaultStudentsGroup);
|
await setDoc(doc(db, "groups", defaultStudentsGroup.id), defaultStudentsGroup);
|
||||||
|
await setDoc(doc(db, "groups", defaultCorporateGroup.id), defaultCorporateGroup);
|
||||||
|
|
||||||
req.session.user = {...user, id: userId};
|
req.session.user = {...user, id: userId};
|
||||||
await req.session.save();
|
await req.session.save();
|
||||||
|
|||||||
44
src/pages/api/training/[id].ts
Normal file
44
src/pages/api/training/[id].ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import {app} from "@/firebase";
|
||||||
|
import { collection, doc, getDoc, getDocs, getFirestore, query } from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET") return get(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
try {
|
||||||
|
const { id } = req.query;
|
||||||
|
|
||||||
|
if (typeof id !== 'string') {
|
||||||
|
return res.status(400).json({ message: 'Invalid ID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const docRef = doc(db, "training", id);
|
||||||
|
const docSnap = await getDoc(docRef);
|
||||||
|
|
||||||
|
if (docSnap.exists()) {
|
||||||
|
res.status(200).json({
|
||||||
|
id: docSnap.id,
|
||||||
|
...docSnap.data(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ message: 'Document not found' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
res.status(500).json({ message: 'An unexpected error occurred' });
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/pages/api/training/index.ts
Normal file
51
src/pages/api/training/index.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import axios from "axios";
|
||||||
|
import { app } from "@/firebase";
|
||||||
|
import { collection, doc, getDoc, getDocs, getFirestore, query } from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET") return get(req, res);
|
||||||
|
if (req.method === "POST") return post(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${process.env.BACKEND_URL}/training_content`, req.body, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.status(response.status).json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ message: 'An unexpected error occurred' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
try {
|
||||||
|
const q = query(collection(db, "training"));
|
||||||
|
const snapshot = await getDocs(q);
|
||||||
|
|
||||||
|
res.status(200).json(
|
||||||
|
snapshot.docs.map((doc) => ({
|
||||||
|
id: doc.id,
|
||||||
|
...doc.data(),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
res.status(500).json({ message: 'An unexpected error occurred' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
44
src/pages/api/training/walkthrough/index.ts
Normal file
44
src/pages/api/training/walkthrough/index.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import { app } from "@/firebase";
|
||||||
|
import { collection, doc, documentId, getDoc, getDocs, getFirestore, query, where } from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method === "GET") return get(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
try {
|
||||||
|
const { ids } = req.query;
|
||||||
|
|
||||||
|
if (!ids || !Array.isArray(ids)) {
|
||||||
|
return res.status(400).json({ message: 'Invalid or missing ids!' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const walkthroughCollection = collection(db, 'walkthrough');
|
||||||
|
|
||||||
|
const q = query(walkthroughCollection, where(documentId(), 'in', ids));
|
||||||
|
|
||||||
|
const querySnapshot = await getDocs(q);
|
||||||
|
|
||||||
|
const documents = querySnapshot.docs.map(doc => ({
|
||||||
|
id: doc.id,
|
||||||
|
...doc.data()
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.status(200).json(documents);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
res.status(500).json({ message: 'An unexpected error occurred' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,22 @@
|
|||||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||||
import {app, adminApp} from "@/firebase";
|
import { app, adminApp } from "@/firebase";
|
||||||
import {Group, User} from "@/interfaces/user";
|
import { Group, User } from "@/interfaces/user";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {collection, deleteDoc, doc, getDoc, getDocs, getFirestore, query, setDoc, where} from "firebase/firestore";
|
import {
|
||||||
import {getAuth} from "firebase-admin/auth";
|
collection,
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
deleteDoc,
|
||||||
import {NextApiRequest, NextApiResponse} from "next";
|
doc,
|
||||||
|
getDoc,
|
||||||
|
getDocs,
|
||||||
|
getFirestore,
|
||||||
|
query,
|
||||||
|
setDoc,
|
||||||
|
where,
|
||||||
|
} from "firebase/firestore";
|
||||||
|
import { getAuth } from "firebase-admin/auth";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { getPermissions, getPermissionDocs } from "@/utils/permissions.be";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
const auth = getAuth(adminApp);
|
const auth = getAuth(adminApp);
|
||||||
@@ -13,89 +24,132 @@ const auth = getAuth(adminApp);
|
|||||||
export default withIronSessionApiRoute(user, sessionOptions);
|
export default withIronSessionApiRoute(user, sessionOptions);
|
||||||
|
|
||||||
async function user(req: NextApiRequest, res: NextApiResponse) {
|
async function user(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method === "GET") return get(req, res);
|
if (req.method === "GET") return get(req, res);
|
||||||
if (req.method === "DELETE") return del(req, res);
|
if (req.method === "DELETE") return del(req, res);
|
||||||
|
|
||||||
res.status(404).json(undefined);
|
res.status(404).json(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del(req: NextApiRequest, res: NextApiResponse) {
|
async function del(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {id} = req.query as {id: string};
|
const { id } = req.query as { id: string };
|
||||||
|
|
||||||
const docUser = await getDoc(doc(db, "users", req.session.user.id));
|
const docUser = await getDoc(doc(db, "users", req.session.user.id));
|
||||||
if (!docUser.exists()) {
|
if (!docUser.exists()) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = docUser.data() as User;
|
const user = docUser.data() as User;
|
||||||
|
|
||||||
const docTargetUser = await getDoc(doc(db, "users", id));
|
const docTargetUser = await getDoc(doc(db, "users", id));
|
||||||
if (!docTargetUser.exists()) {
|
if (!docTargetUser.exists()) {
|
||||||
res.status(404).json({ok: false});
|
res.status(404).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetUser = {...docTargetUser.data(), id: docTargetUser.id} as User;
|
const targetUser = { ...docTargetUser.data(), id: docTargetUser.id } as User;
|
||||||
|
|
||||||
if (user.type === "corporate" && (targetUser.type === "student" || targetUser.type === "teacher")) {
|
if (
|
||||||
res.json({ok: true});
|
user.type === "corporate" &&
|
||||||
|
(targetUser.type === "student" || targetUser.type === "teacher")
|
||||||
|
) {
|
||||||
|
res.json({ ok: true });
|
||||||
|
|
||||||
const userParticipantGroup = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", id)));
|
const userParticipantGroup = await getDocs(
|
||||||
await Promise.all([
|
query(
|
||||||
...userParticipantGroup.docs
|
collection(db, "groups"),
|
||||||
.filter((x) => (x.data() as Group).admin === user.id)
|
where("participants", "array-contains", id)
|
||||||
.map(async (x) => await setDoc(x.ref, {participants: x.data().participants.filter((y: string) => y !== id)}, {merge: true})),
|
)
|
||||||
]);
|
);
|
||||||
|
await Promise.all([
|
||||||
|
...userParticipantGroup.docs
|
||||||
|
.filter((x) => (x.data() as Group).admin === user.id)
|
||||||
|
.map(
|
||||||
|
async (x) =>
|
||||||
|
await setDoc(
|
||||||
|
x.ref,
|
||||||
|
{
|
||||||
|
participants: x
|
||||||
|
.data()
|
||||||
|
.participants.filter((y: string) => y !== id),
|
||||||
|
},
|
||||||
|
{ merge: true }
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const permission = PERMISSIONS.deleteUser[targetUser.type];
|
const permission = PERMISSIONS.deleteUser[targetUser.type];
|
||||||
if (!permission.includes(user.type)) {
|
if (!permission.list.includes(user.type)) {
|
||||||
res.status(403).json({ok: false});
|
res.status(403).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ok: true});
|
res.json({ ok: true });
|
||||||
|
|
||||||
await auth.deleteUser(id);
|
await auth.deleteUser(id);
|
||||||
await deleteDoc(doc(db, "users", id));
|
await deleteDoc(doc(db, "users", id));
|
||||||
const userCodeDocs = await getDocs(query(collection(db, "codes"), where("userId", "==", id)));
|
const userCodeDocs = await getDocs(
|
||||||
const userParticipantGroup = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", id)));
|
query(collection(db, "codes"), where("userId", "==", id))
|
||||||
const userGroupAdminDocs = await getDocs(query(collection(db, "groups"), where("admin", "==", id)));
|
);
|
||||||
const userStatsDocs = await getDocs(query(collection(db, "stats"), where("user", "==", id)));
|
const userParticipantGroup = await getDocs(
|
||||||
|
query(collection(db, "groups"), where("participants", "array-contains", id))
|
||||||
|
);
|
||||||
|
const userGroupAdminDocs = await getDocs(
|
||||||
|
query(collection(db, "groups"), where("admin", "==", id))
|
||||||
|
);
|
||||||
|
const userStatsDocs = await getDocs(
|
||||||
|
query(collection(db, "stats"), where("user", "==", id))
|
||||||
|
);
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
...userCodeDocs.docs.map(async (x) => await deleteDoc(x.ref)),
|
...userCodeDocs.docs.map(async (x) => await deleteDoc(x.ref)),
|
||||||
...userGroupAdminDocs.docs.map(async (x) => await deleteDoc(x.ref)),
|
...userGroupAdminDocs.docs.map(async (x) => await deleteDoc(x.ref)),
|
||||||
...userStatsDocs.docs.map(async (x) => await deleteDoc(x.ref)),
|
...userStatsDocs.docs.map(async (x) => await deleteDoc(x.ref)),
|
||||||
...userParticipantGroup.docs.map(
|
...userParticipantGroup.docs.map(
|
||||||
async (x) => await setDoc(x.ref, {participants: x.data().participants.filter((y: string) => y !== id)}, {merge: true}),
|
async (x) =>
|
||||||
),
|
await setDoc(
|
||||||
]);
|
x.ref,
|
||||||
|
{
|
||||||
|
participants: x.data().participants.filter((y: string) => y !== id),
|
||||||
|
},
|
||||||
|
{ merge: true }
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.session.user) {
|
if (req.session.user) {
|
||||||
const docUser = await getDoc(doc(db, "users", req.session.user.id));
|
const docUser = await getDoc(doc(db, "users", req.session.user.id));
|
||||||
if (!docUser.exists()) {
|
if (!docUser.exists()) {
|
||||||
res.status(401).json(undefined);
|
res.status(401).json(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = docUser.data() as User;
|
const user = docUser.data() as User;
|
||||||
|
|
||||||
req.session.user = {...user, id: req.session.user.id};
|
const permissionDocs = await getPermissionDocs();
|
||||||
await req.session.save();
|
|
||||||
|
|
||||||
res.json({...user, id: req.session.user.id});
|
const userWithPermissions = {
|
||||||
} else {
|
...user,
|
||||||
res.status(401).json(undefined);
|
permissions: getPermissions(req.session.user.id, permissionDocs),
|
||||||
}
|
};
|
||||||
|
req.session.user = {
|
||||||
|
...userWithPermissions,
|
||||||
|
id: req.session.user.id,
|
||||||
|
};
|
||||||
|
await req.session.save();
|
||||||
|
|
||||||
|
res.json({ ...userWithPermissions, id: req.session.user.id });
|
||||||
|
} else {
|
||||||
|
res.status(401).json(undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user