Compare commits
92 Commits
feature/EN
...
feature/up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b37b60be0 | ||
|
|
4347d0cabb | ||
|
|
0403773b8e | ||
|
|
8d99a6b03c | ||
|
|
02320b9484 | ||
|
|
fb077fd8cc | ||
|
|
b5a305485f | ||
|
|
8f5b27e9ce | ||
|
|
9ef04b822a | ||
|
|
a6160c3cf0 | ||
|
|
8f6639b7fc | ||
|
|
6a803fe137 | ||
|
|
d7f6a4dde7 | ||
|
|
6058e510de | ||
|
|
7208530879 | ||
|
|
9b6c545932 | ||
|
|
afb9071758 | ||
|
|
d50393930e | ||
|
|
03e1f2cfa3 | ||
|
|
877d2f359f | ||
|
|
45df9837e7 | ||
|
|
923319051c | ||
|
|
f6b4d6ad52 | ||
|
|
19d16c9cef | ||
|
|
daa27e41b3 | ||
|
|
916fa66446 | ||
|
|
10a3243756 | ||
|
|
a1c7f70329 | ||
|
|
bd2efb0ef5 | ||
|
|
34065f1f6e | ||
|
|
41873f80d7 | ||
|
|
a1b67c017d | ||
|
|
13fd7e1ee5 | ||
|
|
4996417218 | ||
|
|
60d436b5b9 | ||
|
|
8d39a20267 | ||
|
|
5d46d7e453 | ||
|
|
15f9fb320d | ||
|
|
494fc9bab6 | ||
|
|
0c5c024098 | ||
|
|
903a567805 | ||
|
|
df3929d5e6 | ||
|
|
6d62500596 | ||
|
|
e5e4e87752 | ||
|
|
0b3e686f3f | ||
|
|
3da87cce60 | ||
|
|
c9daba17e1 | ||
|
|
5cfd6d56a6 | ||
|
|
ec8c06ca94 | ||
|
|
77a22b3ab3 | ||
|
|
e79139174b | ||
|
|
61a86394ed | ||
|
|
f6741dd80e | ||
|
|
ce6708be6e | ||
|
|
b62cae2e3a | ||
|
|
d73b6d9d12 | ||
|
|
c11906a395 | ||
|
|
a29b0b56d9 | ||
|
|
53dbf99fba | ||
|
|
cb49e15cb0 | ||
|
|
0eddded560 | ||
|
|
11c6f70576 | ||
|
|
6712e89c47 | ||
|
|
9959cf4294 | ||
|
|
daec246835 | ||
|
|
8ea97ee944 | ||
|
|
975f4c8285 | ||
|
|
f0b85409c9 | ||
|
|
bdd862c633 | ||
|
|
4166781f7e | ||
|
|
1f8e9106de | ||
|
|
9e651358d5 | ||
|
|
5aed336c96 | ||
|
|
85b94512e9 | ||
|
|
906646ebce | ||
|
|
96108a4958 | ||
|
|
fb449f2054 | ||
|
|
d5ee3d9519 | ||
|
|
4e20ec6575 | ||
|
|
836b674076 | ||
|
|
5086c6fb09 | ||
|
|
489c9c3b7e | ||
|
|
e3ded29e77 | ||
|
|
16419a5584 | ||
|
|
3e3b24cc30 | ||
|
|
841698ba10 | ||
|
|
d50904611c | ||
|
|
e77fd16d26 | ||
|
|
649f24e4ae | ||
|
|
2f0cbfe74e | ||
|
|
d022bd078a | ||
|
|
c18afee9ad |
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.
|
||||
|
||||
# dependencies
|
||||
|
||||
7594
package-lock.json
generated
7594
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@beam-australia/react-env": "^3.1.1",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@firebase/util": "^1.9.7",
|
||||
"@headlessui/react": "^1.7.13",
|
||||
"@mdi/js": "^7.1.96",
|
||||
"@mdi/react": "^1.6.1",
|
||||
@@ -47,7 +48,6 @@
|
||||
"next": "13.1.6",
|
||||
"nodemailer": "^6.9.5",
|
||||
"nodemailer-express-handlebars": "^6.1.0",
|
||||
"paymob-react": "git+https://github.com/tiago-ecrop/paymob-react-oman.git",
|
||||
"primeicons": "^6.0.1",
|
||||
"primereact": "^9.2.3",
|
||||
"qrcode": "^1.5.3",
|
||||
@@ -68,9 +68,10 @@
|
||||
"react-select": "^5.7.5",
|
||||
"react-string-replace": "^1.1.0",
|
||||
"react-toastify": "^9.1.2",
|
||||
"react-tooltip": "^5.27.1",
|
||||
"react-xarrows": "^2.0.2",
|
||||
"read-excel-file": "^5.7.1",
|
||||
"short-unique-id": "^5.0.2",
|
||||
"short-unique-id": "5.0.2",
|
||||
"stripe": "^13.10.0",
|
||||
"swr": "^2.1.3",
|
||||
"tailwind-scrollbar-hide": "^1.1.7",
|
||||
@@ -96,7 +97,6 @@
|
||||
"autoprefixer": "^10.4.13",
|
||||
"husky": "^8.0.3",
|
||||
"postcss": "^8.4.21",
|
||||
"tailwindcss": "^3.2.4",
|
||||
"types/": "paypal/react-paypal-js"
|
||||
"tailwindcss": "^3.2.4"
|
||||
}
|
||||
}
|
||||
1
public/mat-icon-info.svg
Normal file
1
public/mat-icon-info.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M440-280h80v-240h-80v240Zm40-320q17 0 28.5-11.5T520-640q0-17-11.5-28.5T480-680q-17 0-28.5 11.5T440-640q0 17 11.5 28.5T480-600Zm0 520q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>
|
||||
|
After Width: | Height: | Size: 535 B |
193
src/components/AIDetection.tsx
Normal file
193
src/components/AIDetection.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import Image from "next/image";
|
||||
import clsx from "clsx";
|
||||
import RadialProgressBar from "./RadialProgressBar";
|
||||
import { AIDetectionAttributes } from "@/interfaces/exam";
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import SegmentedProgressBar from "./SegmentedProgressBar";
|
||||
|
||||
|
||||
// Colors and texts scrapped from gpt's zero react bundle
|
||||
const AIDetection: React.FC<AIDetectionAttributes> = ({ predicted_class, confidence_category, class_probabilities, sentences }) => {
|
||||
const probabilityTooltipContent = `
|
||||
GTP's Zero deep learning model predicts the <br/>
|
||||
probability this text has been entirely <br/>
|
||||
generated by AI. For instance, a 40% AI <br/>
|
||||
probability does not indicate that the text<br/>
|
||||
contains 40% AI-written content. Rather, it<br/>
|
||||
indicates the text is more likely to be partially<br/>
|
||||
human written than be entirely AI-written.
|
||||
`;
|
||||
const confidenceTooltipContent = `
|
||||
Confidence scores are a safeguard to better<br/>
|
||||
understand AI identification results. GTP Zero<br/>
|
||||
trained it's deep learning model on a diverse<br/>
|
||||
dataset of millions of human and AI-written<br/>
|
||||
documents. Green scores indicate that you can scan<br/>
|
||||
with confidence that the model has classified<br/>
|
||||
many similar documents with high accuracy.<br/>
|
||||
Red scores indicate that this text is dissimilar<br/>
|
||||
to the ones in their training set, which can impact<br/>
|
||||
the model's accuracy, and to proceed with caution.
|
||||
`;
|
||||
const confidenceKeywords = ["moderately", "highly", "confident", "uncertain"];
|
||||
var confidence = {
|
||||
low: {
|
||||
ai: "GPT Zero is uncertain about this text. If GPT Zero had to classify it, it would be considered",
|
||||
human: "GPT Zero is uncertain about this text. If GPT Zero had to classify it, it would likely be considered",
|
||||
mixed: "GPT Zero is uncertain about this text. If GPT Zero had to classify it, it would likely be a"
|
||||
},
|
||||
medium: {
|
||||
ai: "GPT Zero is moderately confident this text was",
|
||||
human: "GPT Zero is moderately confident this text is entirely",
|
||||
mixed: "GPT Zero is moderately confident this text is a"
|
||||
},
|
||||
high: {
|
||||
ai: "GPT Zero is highly confident this text was",
|
||||
human: "GPT Zero is highly confident this text is entirely",
|
||||
mixed: "GPT Zero is highly confident this text is a"
|
||||
}
|
||||
}
|
||||
var classPrediction = {
|
||||
ai: {
|
||||
background: "bg-ai-detection-result-ai-bg",
|
||||
color: "text-ai-detection-result-ai",
|
||||
text: "ai generated"
|
||||
},
|
||||
mixed: {
|
||||
background: "bg-ai-detection-result-mixed-bg",
|
||||
color: "text-ai-detection-result-mixed",
|
||||
text: "mix of ai and human"
|
||||
},
|
||||
human: {
|
||||
background: "bg-ai-detection-result-human-bg",
|
||||
color: "text-ai-detection-result-human",
|
||||
text: "human"
|
||||
}
|
||||
}
|
||||
const segments = [
|
||||
{ percentage: Math.round(class_probabilities["human"] * 100), subtitle: 'human', color: "ai-detection-result-human" },
|
||||
{ percentage: Math.round(class_probabilities["mixed"] * 100), subtitle: 'mixed', color: "ai-detection-result-mixed" },
|
||||
{ percentage: Math.round(class_probabilities["ai"] * 100), subtitle: 'ai', color: "ai-detection-result-ai" }
|
||||
];
|
||||
const styleConfidenceText = (text: string): [string, string[]] => {
|
||||
const keywords: string[] = [];
|
||||
const styledText = text.split(" ").map(word => {
|
||||
if (confidenceKeywords.includes(word)) {
|
||||
keywords.push(word);
|
||||
return `<span style="font-weight: 500; text-decoration: underline;">${word}</span>`;
|
||||
}
|
||||
return word
|
||||
}).join(" ");
|
||||
return [styledText, keywords];
|
||||
};
|
||||
const confidenceText = confidence[confidence_category][predicted_class];
|
||||
const [styledText, keywords] = styleConfidenceText(confidenceText);
|
||||
const tooltipStyle = {
|
||||
"backgroundColor": "rgb(255, 255, 255)",
|
||||
"color": "#8992B1",
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
borderRadius: '0.125rem'
|
||||
}
|
||||
const highestProbability = Math.max(class_probabilities["ai"], class_probabilities["human"], class_probabilities["mixed"]);
|
||||
const spanTextColor = highestProbability === class_probabilities["ai"]
|
||||
? "#f4bf4f"
|
||||
: highestProbability === class_probabilities["human"]
|
||||
? "#50c08a"
|
||||
: "#93aafb";
|
||||
let spanClassName = highestProbability === class_probabilities["ai"]
|
||||
? "text-ai-detection-result-ai"
|
||||
: highestProbability === class_probabilities["human"]
|
||||
? "text-ai-detection-result-human"
|
||||
: "text-ai-detection-result-mixed";
|
||||
spanClassName = `${spanClassName} font-bold text-lg`
|
||||
const percentage = Math.round(highestProbability * 100)
|
||||
const hasHighlightedForAI = sentences.some(item => item.highlight_sentence_for_ai);
|
||||
return (
|
||||
<>
|
||||
<Tooltip id="probability-tooltip" className="z-50 bg-white shadow-md rounded-sm" style={tooltipStyle} />
|
||||
<Tooltip id="confidence-tooltip" className="z-50 bg-white shadow-md rounded-sm" style={tooltipStyle} />
|
||||
<div className="flex flex-col bg-white p-6 rounded-lg shadow-lg gap-16">
|
||||
<h1 className="text-lg font-semibold">GPT Zero AI Detection Results</h1>
|
||||
<div className="flex flex-row -md:flex-col -lg:gap-0 -xl:gap-10 gap-20 items-stretch -md:items-center">
|
||||
<div className="flex -md:w-5/6 w-1/2 justify-center">
|
||||
<div className="flex flex-col border rounded-xl">
|
||||
<h1 className="border-b p-6 font-medium">Text Classification</h1>
|
||||
<div className="flex flex-row gap-8 items-center p-6">
|
||||
<RadialProgressBar
|
||||
percentage={percentage}
|
||||
text={predicted_class}
|
||||
color={spanTextColor}
|
||||
spanClassName={spanClassName}
|
||||
/>
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<div className="flex flex-row items-center">
|
||||
<span className="mr-2 text-ai-detection-result-ai-text font-semibold text-xl">
|
||||
{`${Math.round(class_probabilities["ai"] * 100)}%`}
|
||||
</span>
|
||||
<span className="text-sm -md:text-xs text-ai-detection-text">Probability AI generated</span>
|
||||
<a data-tooltip-id="probability-tooltip" data-tooltip-html={probabilityTooltipContent} className='ml-1 flex items-center justify-center'>
|
||||
<Image src="/mat-icon-info.svg" width={24} height={24} alt="Probability Tooltip" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<div className={clsx(
|
||||
"rounded-full w-3 h-3",
|
||||
confidence_category == 'low' ?
|
||||
"bg-ai-detection-confidence-low border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-low-transparent"
|
||||
)}></div>
|
||||
<div className={clsx(
|
||||
"rounded-full w-3 h-3",
|
||||
confidence_category == 'medium' ?
|
||||
"bg-ai-detection-confidence-medium border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-medium-transparent"
|
||||
)}></div>
|
||||
<div className={clsx(
|
||||
"rounded-full w-3 h-3 mr-2",
|
||||
confidence_category == 'high' ?
|
||||
"bg-ai-detection-confidence-high border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-high-transparent"
|
||||
)}></div>
|
||||
<span className="text-sm -md:text-xs text-ai-detection-text">{keywords.join(' ')}</span>
|
||||
<a data-tooltip-id="confidence-tooltip" data-tooltip-html={confidenceTooltipContent} className='ml-1 flex items-center justify-center'>
|
||||
<Image src="/mat-icon-info.svg" width={24} height={24} alt="Probability Tooltip" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col border rounded-xl -md:w-5/6 w-2/6">
|
||||
<h1 className="border-b p-6 font-medium">Probability Breakdown</h1>
|
||||
<div className="flex items-center w-full h-full">
|
||||
<SegmentedProgressBar segments={segments} className="w-full px-8 -md:py-8 text-ai-detection-text" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-row items-center">
|
||||
<div dangerouslySetInnerHTML={{ __html: styledText }} className="mr-2"></div>
|
||||
<div className={clsx(
|
||||
"flex items-center justify-center p-2 rounded",
|
||||
classPrediction[predicted_class]['color'],
|
||||
classPrediction[predicted_class]['background']
|
||||
)}>
|
||||
<span className="text-sm">{classPrediction[predicted_class]['text']}</span>
|
||||
</div>
|
||||
</div>
|
||||
{(hasHighlightedForAI && <div>
|
||||
Sentences that are likely written by AI are <span className="font-semibold bg-ai-detection-highlight">highlighted</span>.
|
||||
</div>)}
|
||||
</div>
|
||||
</div >
|
||||
<div>
|
||||
{sentences.map((item, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={item.highlight_sentence_for_ai ? 'bg-ai-detection-highlight' : ''}
|
||||
>
|
||||
{item.sentence}{' '}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default AIDetection;
|
||||
@@ -77,15 +77,8 @@ export default function FillBlanks({
|
||||
onBack,
|
||||
}: FillBlanksExercise & CommonProps) {
|
||||
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 allBlanks = Array.from(text.match(/({{\d+}})/g) || []).map((x) => x.replaceAll("{", "").replaceAll("}", ""));
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => setIsDrawerShowing(!!currentBlankId), 100);
|
||||
}, [currentBlankId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
@@ -94,9 +87,17 @@ export default function FillBlanks({
|
||||
|
||||
const calculateScore = () => {
|
||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||
const correct = answers.filter(
|
||||
(x) => solutions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false,
|
||||
).length;
|
||||
const correct = answers.filter((x) => {
|
||||
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution.toLowerCase();
|
||||
if (!solution) return false;
|
||||
|
||||
const option = words.find((w) =>
|
||||
typeof w === "string" ? w.toLowerCase() === x.solution.toLowerCase() : w.letter.toLowerCase() === x.solution.toLowerCase(),
|
||||
);
|
||||
if (!option) return false;
|
||||
|
||||
return solution === (typeof option === "string" ? option.toLowerCase() : option.word.toLowerCase());
|
||||
}).length;
|
||||
const missing = total - answers.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||
|
||||
return {total, correct, missing};
|
||||
@@ -104,49 +105,29 @@ export default function FillBlanks({
|
||||
|
||||
const renderLines = (line: string) => {
|
||||
return (
|
||||
<span className="text-base leading-5">
|
||||
<div className="text-base leading-5">
|
||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||
const id = match.replaceAll(/[\{\}]/g, "");
|
||||
const userSolution = answers.find((x) => x.id === id);
|
||||
|
||||
return (
|
||||
<button
|
||||
<input
|
||||
className={clsx(
|
||||
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
|
||||
!userSolution && "w-6 h-6 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-white bg-mti-purple-light",
|
||||
"rounded-full hover:text-white focus:ring-0 focus:outline-none focus:!text-white focus:bg-mti-purple transition duration-300 ease-in-out my-1 px-5 py-2 text-center",
|
||||
!userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
|
||||
userSolution && "px-5 py-2 text-center text-mti-purple-dark bg-mti-purple-ultralight",
|
||||
)}
|
||||
onClick={() => setCurrentBlankId(id)}>
|
||||
{userSolution ? userSolution.solution : id}
|
||||
</button>
|
||||
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), {id, solution: e.target.value}])}
|
||||
value={userSolution?.solution}></input>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
{prompt.split("\\n").map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
@@ -163,6 +144,26 @@ export default function FillBlanks({
|
||||
</p>
|
||||
))}
|
||||
</span>
|
||||
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-4">
|
||||
<span className="font-medium text-mti-purple-dark">Options</span>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{words.map((v) => {
|
||||
const text = typeof v === "string" ? v : `${v.letter} - ${v.word}`;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300",
|
||||
!!answers.find((x) => x.solution.toLowerCase() === (typeof v === "string" ? v : v.letter).toLowerCase()) &&
|
||||
"bg-mti-purple-dark text-white",
|
||||
)}
|
||||
key={text}>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
|
||||
@@ -16,12 +16,12 @@ const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mo
|
||||
export default function InteractiveSpeaking({
|
||||
id,
|
||||
title,
|
||||
first_title,
|
||||
second_title,
|
||||
examID,
|
||||
text,
|
||||
type,
|
||||
prompts,
|
||||
userSolutions,
|
||||
updateIndex,
|
||||
onNext,
|
||||
onBack,
|
||||
}: InteractiveSpeakingExercise & CommonProps) {
|
||||
@@ -36,31 +36,6 @@ export default function InteractiveSpeaking({
|
||||
|
||||
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 () => {
|
||||
setIsLoading(true);
|
||||
|
||||
@@ -91,7 +66,9 @@ export default function InteractiveSpeaking({
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setQuestionIndex(0);
|
||||
|
||||
onNext({
|
||||
exercise: id,
|
||||
@@ -111,10 +88,6 @@ export default function InteractiveSpeaking({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userSolutions, mediaBlob, answers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (updateIndex) updateIndex(questionIndex);
|
||||
}, [questionIndex, updateIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) {
|
||||
const answer = {
|
||||
@@ -154,13 +127,10 @@ export default function InteractiveSpeaking({
|
||||
}, [answers, questionIndex]);
|
||||
|
||||
const saveAnswer = async (index: number) => {
|
||||
const previousURL = answers.find((x) => x.questionIndex === questionIndex)?.blob;
|
||||
const audioPath = await saveToStorage(previousURL);
|
||||
|
||||
const answer = {
|
||||
questionIndex,
|
||||
prompt: prompts[questionIndex].text,
|
||||
blob: audioPath ? audioPath : mediaBlob!,
|
||||
blob: mediaBlob!,
|
||||
};
|
||||
|
||||
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 w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="font-semibold">{title}</span>
|
||||
<span className="font-semibold">{!!first_title && !!second_title ? `${first_title} & ${second_title}` : title}</span>
|
||||
</div>
|
||||
{prompts && prompts.length > 0 && (
|
||||
<div className="flex flex-col gap-4 w-full items-center">
|
||||
@@ -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">
|
||||
<p className="text-base font-normal">Record your answer:</p>
|
||||
<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" />
|
||||
{status === "idle" && (
|
||||
@@ -283,9 +253,9 @@ export default function InteractiveSpeaking({
|
||||
</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">
|
||||
<BsTrashFill
|
||||
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};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log(answers);
|
||||
}, [answers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -3,6 +3,7 @@ import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam"
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import clsx from "clsx";
|
||||
import {useEffect, useState} from "react";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import {CommonProps} from ".";
|
||||
import Button from "../Low/Button";
|
||||
|
||||
@@ -14,11 +15,24 @@ function Question({
|
||||
userSolution,
|
||||
onSelectOption,
|
||||
}: 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 (
|
||||
<div className="flex flex-col gap-10">
|
||||
<span className="">
|
||||
{id} - {prompt}
|
||||
</span>
|
||||
{isNaN(Number(id)) ? (
|
||||
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||
) : (
|
||||
<span className="">
|
||||
<>
|
||||
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||
</>
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-4 justify-between">
|
||||
{variant === "image" &&
|
||||
options.map((option) => (
|
||||
@@ -51,16 +65,7 @@ function Question({
|
||||
);
|
||||
}
|
||||
|
||||
export default function MultipleChoice({
|
||||
id,
|
||||
prompt,
|
||||
type,
|
||||
questions,
|
||||
userSolutions,
|
||||
updateIndex,
|
||||
onNext,
|
||||
onBack,
|
||||
}: MultipleChoiceExercise & CommonProps) {
|
||||
export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
||||
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
|
||||
|
||||
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||
@@ -79,10 +84,6 @@ export default function MultipleChoice({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasExamEnded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (updateIndex) updateIndex(questionIndex);
|
||||
}, [questionIndex, updateIndex]);
|
||||
|
||||
const onSelectOption = (option: string) => {
|
||||
const question = questions[questionIndex];
|
||||
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);
|
||||
|
||||
@@ -7,18 +7,20 @@ import Button from "../Low/Button";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {downloadBlob} from "@/utils/evaluation";
|
||||
import axios from "axios";
|
||||
import Modal from "../Modal";
|
||||
|
||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function Speaking({id, title, text, video_url, type, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||
export default function Speaking({id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||
const [audioURL, setAudioURL] = useState<string>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isPromptsModalOpen, setIsPromptsModalOpen] = useState(false);
|
||||
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
@@ -74,26 +76,18 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
|
||||
}, [isRecording]);
|
||||
|
||||
const next = async () => {
|
||||
setIsLoading(true);
|
||||
const storagePath = await saveToStorage();
|
||||
setIsLoading(false);
|
||||
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: storagePath ? [{id, solution: storagePath}] : [],
|
||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||
score: {correct: 0, total: 100, missing: 0},
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
const back = async () => {
|
||||
setIsLoading(true);
|
||||
const storagePath = await saveToStorage();
|
||||
setIsLoading(false);
|
||||
|
||||
onBack({
|
||||
exercise: id,
|
||||
solutions: storagePath ? [{id, solution: storagePath}] : [],
|
||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||
score: {correct: 0, total: 100, missing: 0},
|
||||
type,
|
||||
});
|
||||
@@ -101,9 +95,26 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full gap-9">
|
||||
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
|
||||
<div className="flex flex-col items-center justify-center gap-4 w-full h-full">
|
||||
<div className="flex flex-col gap-1 ml-4">
|
||||
{prompts.map((x, index) => (
|
||||
<li className="italic" key={index}>
|
||||
{x}
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
{!!suffix && <span className="font-bold">{suffix}</span>}
|
||||
</div>
|
||||
</Modal>
|
||||
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="font-semibold">{title}</span>
|
||||
<div className="flex flex-col gap-0">
|
||||
<span className="font-semibold">{title}</span>
|
||||
{prompts.length > 0 && (
|
||||
<span className="font-semibold">You should talk for at least 30 seconds for your answer to be valid.</span>
|
||||
)}
|
||||
</div>
|
||||
{!video_url && (
|
||||
<span className="font-regular">
|
||||
{text.split("\\n").map((line, index) => (
|
||||
@@ -115,7 +126,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
<div className="flex flex-col gap-6 items-center">
|
||||
{video_url && (
|
||||
<div className="flex flex-col gap-4 w-full items-center">
|
||||
<video key={id} autoPlay controls className="max-w-3xl rounded-xl">
|
||||
@@ -123,18 +134,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, use
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
{prompts && prompts.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
{prompts && prompts.length > 0 && <Button onClick={() => setIsPromptsModalOpen(true)}>View Prompts</Button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentenc
|
||||
|
||||
export interface CommonProps {
|
||||
examID?: string;
|
||||
updateIndex?: (internalIndex: number) => void;
|
||||
onNext: (userSolutions: UserSolution) => void;
|
||||
onBack: (userSolutions: UserSolution) => void;
|
||||
}
|
||||
@@ -33,7 +32,6 @@ export const renderExercise = (
|
||||
examID: string,
|
||||
onNext: (userSolutions: UserSolution) => void,
|
||||
onBack: (userSolutions: UserSolution) => void,
|
||||
updateIndex?: (internalIndex: number) => void,
|
||||
) => {
|
||||
switch (exercise.type) {
|
||||
case "fillBlanks":
|
||||
@@ -43,16 +41,7 @@ export const renderExercise = (
|
||||
case "matchSentences":
|
||||
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||
case "multipleChoice":
|
||||
return (
|
||||
<MultipleChoice
|
||||
key={exercise.id}
|
||||
{...(exercise as MultipleChoiceExercise)}
|
||||
updateIndex={updateIndex}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
examID={examID}
|
||||
/>
|
||||
);
|
||||
return <MultipleChoice key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||
case "writeBlanks":
|
||||
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
|
||||
case "writing":
|
||||
@@ -65,7 +54,6 @@ export const renderExercise = (
|
||||
key={exercise.id}
|
||||
{...(exercise as InteractiveSpeakingExercise)}
|
||||
examID={examID}
|
||||
updateIndex={updateIndex}
|
||||
onNext={onNext}
|
||||
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}
|
||||
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
|
||||
className="-md:hidden"
|
||||
userType={user.type}
|
||||
userId={user.id}
|
||||
user={user}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
|
||||
@@ -16,6 +16,7 @@ import ShortUniqueId from "short-unique-id";
|
||||
import Button from "../Low/Button";
|
||||
import Input from "../Low/Input";
|
||||
import Select from "../Low/Select";
|
||||
import { checkAccess } from "@/utils/permissions";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
@@ -137,13 +138,13 @@ export default function TicketDisplay({ user, ticket, onClose }: Props) {
|
||||
options={[
|
||||
{ value: "me", label: "Assign to me" },
|
||||
...users
|
||||
.filter((x) => ["admin", "developer", "agent"].includes(x.type))
|
||||
.filter((x) => checkAccess(x, ["admin", "developer", "agent"]))
|
||||
.map((u) => ({
|
||||
value: u.id,
|
||||
label: `${u.name} - ${u.email}`,
|
||||
})),
|
||||
]}
|
||||
disabled={user.type === "agent"}
|
||||
disabled={checkAccess(user, ["agent"])}
|
||||
value={
|
||||
assignedTo
|
||||
? {
|
||||
|
||||
@@ -4,7 +4,7 @@ import ReactSelect, {GroupBase, StylesConfig} from "react-select";
|
||||
|
||||
interface Option {
|
||||
[key: string]: any;
|
||||
value: string;
|
||||
value: string | null;
|
||||
label: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,160 +1,215 @@
|
||||
import {User} from "@/interfaces/user";
|
||||
import {Dialog, Transition} from "@headlessui/react";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import {useRouter} from "next/router";
|
||||
import {Fragment} from "react";
|
||||
import {BsXLg} from "react-icons/bs";
|
||||
import { useRouter } from "next/router";
|
||||
import { Fragment } from "react";
|
||||
import { BsXLg } from "react-icons/bs";
|
||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
path: string;
|
||||
user: User;
|
||||
disableNavigation?: boolean;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
path: string;
|
||||
user: User;
|
||||
disableNavigation?: boolean;
|
||||
}
|
||||
|
||||
export default function MobileMenu({isOpen, onClose, path, user, disableNavigation}: Props) {
|
||||
const router = useRouter();
|
||||
export default function MobileMenu({
|
||||
isOpen,
|
||||
onClose,
|
||||
path,
|
||||
user,
|
||||
disableNavigation,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const logout = async () => {
|
||||
axios.post("/api/logout").finally(() => {
|
||||
setTimeout(() => router.reload(), 500);
|
||||
});
|
||||
};
|
||||
const logout = async () => {
|
||||
axios.post("/api/logout").finally(() => {
|
||||
setTimeout(() => router.reload(), 500);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||
</Transition.Child>
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center text-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95">
|
||||
<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">
|
||||
<Link href={disableNavigation ? "" : "/"}>
|
||||
<Image src="/logo_title.png" alt="EnCoach logo" width={69} height={69} />
|
||||
</Link>
|
||||
<div className="cursor-pointer" onClick={onClose} tabIndex={0}>
|
||||
<BsXLg className="text-mti-purple-light text-2xl" onClick={onClose} />
|
||||
</div>
|
||||
</Dialog.Title>
|
||||
<div className="flex h-full flex-col gap-6 px-8 text-lg">
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Dashboard
|
||||
</Link>
|
||||
{(user.type === "student" || user.type === "teacher" || user.type === "developer") && (
|
||||
<>
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/exam"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/exam" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Exams
|
||||
</Link>
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/exercises"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/exercises" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Exercises
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/stats"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/stats" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Stats
|
||||
</Link>
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/record"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/record" && "text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Record
|
||||
</Link>
|
||||
{["admin", "developer", "agent", "corporate"].includes(user.type) && (
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/payment-record"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/payment-record" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold ",
|
||||
)}>
|
||||
Payment Record
|
||||
</Link>
|
||||
)}
|
||||
{["admin", "developer", "corporate", "teacher"].includes(user.type) && (
|
||||
<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>
|
||||
)}
|
||||
{["admin", "developer", "agent"].includes(user.type) && (
|
||||
<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>
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center text-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<Link href={disableNavigation ? "" : "/"}>
|
||||
<Image
|
||||
src="/logo_title.png"
|
||||
alt="EnCoach logo"
|
||||
width={69}
|
||||
height={69}
|
||||
/>
|
||||
</Link>
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={onClose}
|
||||
tabIndex={0}
|
||||
>
|
||||
<BsXLg
|
||||
className="text-mti-purple-light text-2xl"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
</Dialog.Title>
|
||||
<div className="flex h-full flex-col gap-6 px-8 text-lg">
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||
)}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
{checkAccess(user, ["student", "teacher", "developer"]) && (
|
||||
<>
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/exam"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/exam" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||
)}
|
||||
>
|
||||
Exams
|
||||
</Link>
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/exercises"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/exercises" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||
)}
|
||||
>
|
||||
Exercises
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/stats"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/stats" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||
)}
|
||||
>
|
||||
Stats
|
||||
</Link>
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/record"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/record" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||
)}
|
||||
>
|
||||
Record
|
||||
</Link>
|
||||
{checkAccess(user, [
|
||||
"admin",
|
||||
"developer",
|
||||
"agent",
|
||||
"corporate",
|
||||
"mastercorporate",
|
||||
]) && (
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/payment-record"}
|
||||
className={clsx(
|
||||
"w-fit transition duration-300 ease-in-out",
|
||||
path === "/payment-record" &&
|
||||
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
|
||||
)}
|
||||
>
|
||||
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
|
||||
className={clsx("w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out")}
|
||||
onClick={logout}>
|
||||
Logout
|
||||
</span>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
<span
|
||||
className={clsx(
|
||||
"w-fit cursor-pointer justify-self-end transition duration-300 ease-in-out"
|
||||
)}
|
||||
onClick={logout}
|
||||
>
|
||||
Logout
|
||||
</span>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import {Dialog, Transition} from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import {Fragment, ReactElement} from "react";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
className?: string;
|
||||
children?: ReactElement;
|
||||
}
|
||||
|
||||
export default function Modal({isOpen, title, onClose, children}: Props) {
|
||||
export default function Modal({isOpen, title, className, onClose, children}: Props) {
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<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"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
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 && (
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
||||
{title}
|
||||
|
||||
@@ -1,118 +1,219 @@
|
||||
import {User} from "@/interfaces/user";
|
||||
import { User } from "@/interfaces/user";
|
||||
import Link from "next/link";
|
||||
import FocusLayer from "@/components/FocusLayer";
|
||||
import {preventNavigation} from "@/utils/navigation.disabled";
|
||||
import {useRouter} from "next/router";
|
||||
import {BsList, BsQuestionCircle, BsQuestionCircleFill} from "react-icons/bs";
|
||||
import { preventNavigation } from "@/utils/navigation.disabled";
|
||||
import { useRouter } from "next/router";
|
||||
import { BsList, BsQuestionCircle, BsQuestionCircleFill } from "react-icons/bs";
|
||||
import clsx from "clsx";
|
||||
import moment from "moment";
|
||||
import MobileMenu from "./MobileMenu";
|
||||
import {useEffect, useState} from "react";
|
||||
import {Type} from "@/interfaces/user";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Type } from "@/interfaces/user";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import {isUserFromCorporate} from "@/utils/groups";
|
||||
import { isUserFromCorporate } from "@/utils/groups";
|
||||
import Button from "./Low/Button";
|
||||
import Modal from "./Modal";
|
||||
import Input from "./Low/Input";
|
||||
import TicketSubmission from "./High/TicketSubmission";
|
||||
import { Module } from "@/interfaces";
|
||||
import Badge from "./Low/Badge";
|
||||
|
||||
import {
|
||||
BsArrowRepeat,
|
||||
BsBook,
|
||||
BsCheck,
|
||||
BsCheckCircle,
|
||||
BsClipboard,
|
||||
BsHeadphones,
|
||||
BsMegaphone,
|
||||
BsPen,
|
||||
BsXCircle,
|
||||
} from "react-icons/bs";
|
||||
interface Props {
|
||||
user: User;
|
||||
navDisabled?: boolean;
|
||||
focusMode?: boolean;
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
path: string;
|
||||
user: User;
|
||||
navDisabled?: boolean;
|
||||
focusMode?: boolean;
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
||||
const [isTicketOpen, setIsTicketOpen] = useState(false);
|
||||
export default function Navbar({
|
||||
user,
|
||||
path,
|
||||
navDisabled = false,
|
||||
focusMode = false,
|
||||
onFocusLayerMouseEnter,
|
||||
}: Props) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
||||
const [isTicketOpen, setIsTicketOpen] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const router = useRouter();
|
||||
|
||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||
|
||||
const expirationDateColor = (date: Date) => {
|
||||
const momentDate = moment(date);
|
||||
const today = moment(new Date());
|
||||
const expirationDateColor = (date: Date) => {
|
||||
const momentDate = moment(date);
|
||||
const today = moment(new Date());
|
||||
|
||||
if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light";
|
||||
if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light";
|
||||
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light";
|
||||
};
|
||||
if (today.add(1, "days").isAfter(momentDate))
|
||||
return "!bg-mti-red-ultralight border-mti-red-light";
|
||||
if (today.add(3, "days").isAfter(momentDate))
|
||||
return "!bg-mti-rose-ultralight border-mti-rose-light";
|
||||
if (today.add(7, "days").isAfter(momentDate))
|
||||
return "!bg-mti-orange-ultralight border-mti-orange-light";
|
||||
};
|
||||
|
||||
const showExpirationDate = () => {
|
||||
if (!user.subscriptionExpirationDate) return false;
|
||||
const showExpirationDate = () => {
|
||||
if (!user.subscriptionExpirationDate) return false;
|
||||
|
||||
const momentDate = moment(user.subscriptionExpirationDate);
|
||||
const today = moment(new Date());
|
||||
const momentDate = moment(user.subscriptionExpirationDate);
|
||||
const today = moment(new Date());
|
||||
|
||||
return today.add(7, "days").isAfter(momentDate);
|
||||
};
|
||||
return today.add(7, "days").isAfter(momentDate);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user.type !== "student" && user.type !== "teacher") return setDisablePaymentPage(false);
|
||||
isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result));
|
||||
}, [user]);
|
||||
useEffect(() => {
|
||||
if (user.type !== "student" && user.type !== "teacher")
|
||||
return setDisablePaymentPage(false);
|
||||
isUserFromCorporate(user.id).then((result) =>
|
||||
setDisablePaymentPage(result)
|
||||
);
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={isTicketOpen} onClose={() => setIsTicketOpen(false)} title="Submit a ticket">
|
||||
<TicketSubmission user={user} page={router.asPath} onClose={() => setIsTicketOpen(false)} />
|
||||
</Modal>
|
||||
const badges = [
|
||||
{
|
||||
module: "reading",
|
||||
icon: () => <BsBook className="h-4 w-4 text-white" />,
|
||||
achieved: user.levels.reading >= user.desiredLevels.reading,
|
||||
},
|
||||
|
||||
{user && (
|
||||
<MobileMenu disableNavigation={disableNavigation} path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />
|
||||
)}
|
||||
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
|
||||
<Link href={disableNavigation ? "" : "/"} className=" flex items-center gap-8 md:px-8">
|
||||
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
|
||||
<h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1>
|
||||
</Link>
|
||||
<div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6">
|
||||
{/* 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>
|
||||
{
|
||||
module: "listening",
|
||||
icon: () => <BsHeadphones className="h-4 w-4 text-white" />,
|
||||
achieved: user.levels.listening >= user.desiredLevels.listening,
|
||||
},
|
||||
{
|
||||
module: "writing",
|
||||
icon: () => <BsPen className="h-4 w-4 text-white" />,
|
||||
achieved: user.levels.writing >= user.desiredLevels.writing,
|
||||
},
|
||||
{
|
||||
module: "speaking",
|
||||
icon: () => <BsMegaphone className="h-4 w-4 text-white" />,
|
||||
achieved: user.levels.speaking >= user.desiredLevels.speaking,
|
||||
},
|
||||
{
|
||||
module: "level",
|
||||
icon: () => <BsClipboard className="h-4 w-4 text-white" />,
|
||||
achieved: user.levels.level >= user.desiredLevels.level,
|
||||
},
|
||||
];
|
||||
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isTicketOpen}
|
||||
onClose={() => setIsTicketOpen(false)}
|
||||
title="Submit a ticket"
|
||||
>
|
||||
<TicketSubmission
|
||||
user={user}
|
||||
page={router.asPath}
|
||||
onClose={() => setIsTicketOpen(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{user && (
|
||||
<MobileMenu
|
||||
disableNavigation={disableNavigation}
|
||||
path={path}
|
||||
isOpen={isMenuOpen}
|
||||
onClose={() => setIsMenuOpen(false)}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
<header className="-md:justify-between -md:px-4 relative flex w-full items-center bg-transparent py-2 md:gap-12 md:py-4">
|
||||
<Link
|
||||
href={disableNavigation ? "" : "/"}
|
||||
className=" flex items-center gap-8 md:px-8"
|
||||
>
|
||||
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" />
|
||||
<h1 className="-md:hidden w-1/6 text-2xl font-bold">EnCoach</h1>
|
||||
</Link>
|
||||
<div className="flex items-center justify-end gap-4 md:mr-8 md:w-5/6">
|
||||
{user.type === "student" &&
|
||||
badges.map((badge) => (
|
||||
<div
|
||||
key={badge.module}
|
||||
className={`${badge.achieved ? `bg-ielts-${badge.module}`: 'bg-mti-gray-anti-flash'} flex h-8 w-8 items-center justify-center rounded-full`}
|
||||
>
|
||||
{badge.icon()}
|
||||
</div>
|
||||
))}
|
||||
{/* OPEN TICKET SYSTEM */}
|
||||
<button
|
||||
className={clsx(
|
||||
"border-mti-purple-light tooltip tooltip-bottom flex h-8 w-8 flex-col items-center justify-center rounded-full border p-1",
|
||||
"hover:bg-mti-purple-light transition duration-300 ease-in-out hover:text-white z-20"
|
||||
)}
|
||||
data-tip="Submit a help/feedback ticket"
|
||||
onClick={() => setIsTicketOpen(true)}
|
||||
>
|
||||
<BsQuestionCircleFill />
|
||||
</button>
|
||||
|
||||
{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;
|
||||
}
|
||||
@@ -20,15 +20,6 @@ interface Props {
|
||||
|
||||
export default function PaymobPayment({user, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess}: Props) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const [firstName, setFirstName] = useState(user.name.split(" ")[0]);
|
||||
const [lastName, setLastName] = useState([...user.name.split(" ")].pop());
|
||||
const [street, setStreet] = useState("");
|
||||
const [apartment, setApartment] = useState("");
|
||||
const [building, setBuilding] = useState("");
|
||||
const [state, setState] = useState("");
|
||||
const [floor, setFloor] = useState("");
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -50,16 +41,16 @@ export default function PaymobPayment({user, price, setIsPaymentLoading, currenc
|
||||
},
|
||||
},
|
||||
billing_data: {
|
||||
apartment: apartment || "N/A",
|
||||
building: building || "N/A",
|
||||
apartment: "N/A",
|
||||
building: "N/A",
|
||||
country: user.demographicInformation?.country || "N/A",
|
||||
email: user.email,
|
||||
first_name: user.name.split(" ")[0],
|
||||
last_name: [...user.name.split(" ")].pop() || "N/A",
|
||||
floor: floor || "N/A",
|
||||
floor: "N/A",
|
||||
phone_number: user.demographicInformation?.phone || "N/A",
|
||||
state: state || "N/A",
|
||||
street: street || "N/A",
|
||||
state: "N/A",
|
||||
street: "N/A",
|
||||
},
|
||||
extras: {
|
||||
userID: user.id,
|
||||
@@ -71,7 +62,6 @@ export default function PaymobPayment({user, price, setIsPaymentLoading, currenc
|
||||
const response = await axios.post<{iframeURL: string}>(`/api/paymob`, paymentIntention);
|
||||
|
||||
router.push(response.data.iframeURL);
|
||||
setIsModalOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Error starting card payment process:", error);
|
||||
}
|
||||
@@ -79,27 +69,7 @@ export default function PaymobPayment({user, price, setIsPaymentLoading, currenc
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={isModalOpen} title="Billing Data" onClose={() => setIsModalOpen(false)}>
|
||||
<div className="flex flex-col gap-4 mt-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="First Name" value={firstName} onChange={setFirstName} type="text" name="firstName" />
|
||||
<Input label="Last Name" value={lastName} onChange={setLastName} type="text" name="lastName" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 -md:grid-cols-1 gap-4">
|
||||
<Input label="State" value={state} onChange={setState} type="text" name="state" />
|
||||
<Input label="Street" value={street} onChange={setStreet} type="text" name="street" />
|
||||
<Input label="Building" value={building} onChange={setBuilding} type="text" name="building" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="Floor" value={floor} onChange={setFloor} type="text" name="floor" />
|
||||
<Input label="Apartment" value={apartment} onChange={setApartment} type="text" name="apartment" />
|
||||
</div>
|
||||
<Button className="w-full max-w-[200px] self-end mt-4" disabled={!firstName || !lastName} onClick={handleCardPayment}>
|
||||
Complete Payment
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
<Button isLoading={isLoading} onClick={() => setIsModalOpen(true)}>
|
||||
<Button isLoading={isLoading} onClick={handleCardPayment}>
|
||||
Select
|
||||
</Button>
|
||||
</>
|
||||
|
||||
62
src/components/PermissionList.tsx
Normal file
62
src/components/PermissionList.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import {Permission} from "@/interfaces/permissions";
|
||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||
import Link from "next/link";
|
||||
import {convertCamelCaseToReadable} from "@/utils/string";
|
||||
|
||||
interface Props {
|
||||
permissions: Permission[];
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<Permission>();
|
||||
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("type", {
|
||||
header: () => <span>Type</span>,
|
||||
cell: ({row, getValue}) => (
|
||||
<Link
|
||||
href={`/permissions/${row.original.id}`}
|
||||
key={row.id}
|
||||
className="underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer">
|
||||
{convertCamelCaseToReadable(getValue() as string)}
|
||||
</Link>
|
||||
),
|
||||
}),
|
||||
];
|
||||
|
||||
export default function PermissionList({permissions}: Props) {
|
||||
const table = useReactTable({
|
||||
data: permissions,
|
||||
columns: defaultColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th className="py-4 px-4 text-left" key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="px-2">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
src/components/RadialProgressBar.tsx
Normal file
64
src/components/RadialProgressBar.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
|
||||
interface RadialProgressBarProps {
|
||||
percentage: number;
|
||||
text: string;
|
||||
color: string;
|
||||
spanClassName?: string;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
strokeOpacity?: number;
|
||||
}
|
||||
|
||||
|
||||
// https://gist.github.com/eYinka/873be69fae3ef27b103681b8a9f5e379 Omarmarei's answer
|
||||
const RadialProgressBar: React.FC<RadialProgressBarProps> = ({
|
||||
percentage,
|
||||
text,
|
||||
color,
|
||||
spanClassName = "",
|
||||
size = 100,
|
||||
strokeWidth = 10,
|
||||
strokeOpacity = 0.5
|
||||
}) => {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (percentage / 100) * circumference;
|
||||
return (
|
||||
<div className='relative flex items-center justify-center' style={{ width: size, height: size}}>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`
|
||||
}
|
||||
className="circular-progress-bar"
|
||||
>
|
||||
<circle className="circle-bg" stroke="#e6e6e6" strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
strokeOpacity={strokeOpacity}
|
||||
/>
|
||||
<circle
|
||||
className="circle-progress"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
strokeOpacity={strokeOpacity}
|
||||
/>
|
||||
</svg>
|
||||
<span className={clsx('absolute', spanClassName)}>{text}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default RadialProgressBar;
|
||||
48
src/components/SegmentedProgressBar.tsx
Normal file
48
src/components/SegmentedProgressBar.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
interface Segment {
|
||||
percentage: number;
|
||||
subtitle: string;
|
||||
color: string;
|
||||
}
|
||||
interface SegmentedProgressBarProps {
|
||||
segments: Segment[];
|
||||
height?: number;
|
||||
className?: string;
|
||||
}
|
||||
const SegmentedProgressBar: React.FC<SegmentedProgressBarProps> = ({ segments, height=15, className="" }) => {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="relative flex rounded-full overflow-hidden bg-gray-200" style={{height: `${height}px`}}>
|
||||
{segments.map((segment, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={clsx(
|
||||
'h-full opacity-50',
|
||||
'transition-all duration-500 ease-out',
|
||||
`bg-${segment.color}`,
|
||||
{
|
||||
'rounded-l-full': index === 0,
|
||||
'rounded-r-full': index === segments.length - 1,
|
||||
'rounded-none': index !== 0 && index !== segments.length - 1
|
||||
}
|
||||
)}
|
||||
style={{width: `${segment.percentage}%`}}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex text-sm justify-between">
|
||||
{segments.map((segment, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col text-center w-fit"
|
||||
>
|
||||
<span className={clsx('font-semibold',`text-${segment.color}`)}>{segment.subtitle}</span>
|
||||
<span>{`${segment.percentage}%`}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default SegmentedProgressBar;
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
BsCloudFill,
|
||||
BsCurrencyDollar,
|
||||
BsClipboardData,
|
||||
BsFileLock,
|
||||
} from "react-icons/bs";
|
||||
import {RiLogoutBoxFill} from "react-icons/ri";
|
||||
import {SlPencil} from "react-icons/sl";
|
||||
@@ -23,16 +24,16 @@ import FocusLayer from "@/components/FocusLayer";
|
||||
import {preventNavigation} from "@/utils/navigation.disabled";
|
||||
import {useEffect, useState} from "react";
|
||||
import usePreferencesStore from "@/stores/preferencesStore";
|
||||
import {Type} from "@/interfaces/user";
|
||||
import {User} from "@/interfaces/user";
|
||||
import useTicketsListener from "@/hooks/useTicketsListener";
|
||||
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
||||
interface Props {
|
||||
path: string;
|
||||
navDisabled?: boolean;
|
||||
focusMode?: boolean;
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
className?: string;
|
||||
userType?: Type;
|
||||
userId?: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
interface NavProps {
|
||||
@@ -72,12 +73,12 @@ const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false,
|
||||
);
|
||||
};
|
||||
|
||||
export default function Sidebar({path, navDisabled = false, focusMode = false, userType, onFocusLayerMouseEnter, className, userId}: Props) {
|
||||
export default function Sidebar({path, navDisabled = false, focusMode = false, user, onFocusLayerMouseEnter, className}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
|
||||
|
||||
const {totalAssignedTickets} = useTicketsListener(userId);
|
||||
const {totalAssignedTickets} = useTicketsListener(user.id);
|
||||
|
||||
const logout = async () => {
|
||||
axios.post("/api/logout").finally(() => {
|
||||
@@ -96,33 +97,19 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
||||
)}>
|
||||
<div className="-xl:hidden flex-col gap-3 xl:flex">
|
||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
|
||||
{(userType === "student" || userType === "teacher" || userType === "developer") && (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
{checkAccess(user, ["student", "teacher", "developer"], "viewExams") && (
|
||||
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={isMinimized} />
|
||||
)}
|
||||
{(userType || "") !== 'agent' && (
|
||||
<>
|
||||
<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} />
|
||||
</>
|
||||
{checkAccess(user, ["student", "teacher", "developer"], "viewExercises") && (
|
||||
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={isMinimized} />
|
||||
)}
|
||||
{["admin", "developer", "agent", "corporate"].includes(userType || "") && (
|
||||
{checkAccess(user, getTypesOfUser(["agent"]), "viewStats") && (
|
||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
|
||||
)}
|
||||
{checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && (
|
||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
|
||||
)}
|
||||
{checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"], "viewPaymentRecords") && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCurrencyDollar}
|
||||
@@ -132,7 +119,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{["admin", "developer", "corporate", "teacher"].includes(userType || "") && (
|
||||
{checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]) && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsShieldFill}
|
||||
@@ -142,7 +129,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
)}
|
||||
{["admin", "developer", "agent"].includes(userType || "") && (
|
||||
{checkAccess(user, ["admin", "developer", "agent"], "viewTickets") && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsClipboardData}
|
||||
@@ -153,32 +140,62 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
||||
badge={totalAssignedTickets}
|
||||
/>
|
||||
)}
|
||||
{userType === "developer" && (
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCloudFill}
|
||||
label="Generation"
|
||||
path={path}
|
||||
keyPath="/generation"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
{checkAccess(user, ["developer", "admin"]) && (
|
||||
<>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsCloudFill}
|
||||
label="Generation"
|
||||
path={path}
|
||||
keyPath="/generation"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
<Nav
|
||||
disabled={disableNavigation}
|
||||
Icon={BsFileLock}
|
||||
label="Permissions"
|
||||
path={path}
|
||||
keyPath="/permissions"
|
||||
isMinimized={isMinimized}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="-xl:flex flex-col gap-3 xl:hidden">
|
||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
|
||||
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
|
||||
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
|
||||
{(userType || "") !== 'agent' && (
|
||||
<>
|
||||
<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} />
|
||||
</>
|
||||
{checkAccess(user, getTypesOfUser(["agent"]), "viewStats") && (
|
||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
|
||||
)}
|
||||
{userType !== "student" && (
|
||||
{checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && (
|
||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
|
||||
)}
|
||||
{checkAccess(user, getTypesOfUser(["student"])) && (
|
||||
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
|
||||
)}
|
||||
{userType === "developer" && (
|
||||
<Nav disabled={disableNavigation} Icon={BsCloudFill} label="Generation" path={path} keyPath="/generation" isMinimized={true} />
|
||||
{checkAccess(user, getTypesOfUser(["student"])) && (
|
||||
<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>
|
||||
|
||||
|
||||
@@ -5,12 +5,30 @@ import {CommonProps} from ".";
|
||||
import {Fragment} from "react";
|
||||
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 total = text.match(/({{\d+}})/g)?.length || 0;
|
||||
const correct = userSolutions.filter(
|
||||
(x) => solutions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false,
|
||||
).length;
|
||||
const correct = userSolutions.filter((x) => {
|
||||
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution.toLowerCase();
|
||||
if (!solution) return false;
|
||||
|
||||
const option = words.find((w) =>
|
||||
typeof w === "string" ? w.toLowerCase() === x.solution.toLowerCase() : w.letter.toLowerCase() === x.solution.toLowerCase(),
|
||||
);
|
||||
if (!option) return false;
|
||||
|
||||
return solution === (typeof option === "string" ? option.toLowerCase() : option.word.toLowerCase());
|
||||
}).length;
|
||||
const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||
|
||||
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 (
|
||||
<button
|
||||
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 (
|
||||
<>
|
||||
<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",
|
||||
userSolution && "px-5 py-2 text-center text-white bg-mti-rose-light",
|
||||
)}>
|
||||
{userSolution.solution}
|
||||
{userSolutionText}
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
||||
@@ -132,11 +132,16 @@ export default function InteractiveSpeaking({
|
||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex gap-4 px-1">
|
||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
|
||||
<div className="bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2" key={key}>
|
||||
{key}: Level {userSolutions[0].evaluation!.task_response[key]}
|
||||
</div>
|
||||
))}
|
||||
{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 className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2")} key={key}>
|
||||
{key}: Level {grade}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{userSolutions[0].evaluation &&
|
||||
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length === 3 ? (
|
||||
@@ -186,6 +191,17 @@ export default function InteractiveSpeaking({
|
||||
}>
|
||||
Recommended Answer (Prompt 3)
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||
)
|
||||
}>
|
||||
Global Overview
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||
@@ -193,19 +209,36 @@ export default function InteractiveSpeaking({
|
||||
</Tab.Panel>
|
||||
<Tab.Panel 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_1!.replaceAll(/\s{2,}/g, "\n\n")}
|
||||
{userSolutions[0].evaluation!.perfect_answer_1!.answer.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_2!.replaceAll(/\s{2,}/g, "\n\n")}
|
||||
{userSolutions[0].evaluation!.perfect_answer_2!.answer.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")}
|
||||
{userSolutions[0].evaluation!.perfect_answer_3!.answer.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">
|
||||
<div className="flex flex-col gap-4">
|
||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
||||
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
||||
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-2">
|
||||
<span className={"font-semibold"}>
|
||||
{key}: Level {grade}
|
||||
</span>
|
||||
{typeof taskResponse !== "number" && <span>{taskResponse.comment}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import clsx from "clsx";
|
||||
import {useEffect, useState} from "react";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import {CommonProps} from ".";
|
||||
import Button from "../Low/Button";
|
||||
|
||||
@@ -13,6 +15,13 @@ function Question({
|
||||
options,
|
||||
userSolution,
|
||||
}: 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) => {
|
||||
if (option === solution && !userSolution) {
|
||||
return "!border-mti-gray-davy !text-mti-gray-davy";
|
||||
@@ -27,9 +36,15 @@ function Question({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<span>
|
||||
{id} - {prompt}
|
||||
</span>
|
||||
{isNaN(Number(id)) ? (
|
||||
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||
) : (
|
||||
<span className="">
|
||||
<>
|
||||
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||
</>
|
||||
</span>
|
||||
)}
|
||||
<div className="grid grid-cols-4 gap-4 place-items-center">
|
||||
{variant === "image" &&
|
||||
options.map((option) => (
|
||||
@@ -57,17 +72,8 @@ function Question({
|
||||
);
|
||||
}
|
||||
|
||||
export default function MultipleChoice({
|
||||
id,
|
||||
type,
|
||||
prompt,
|
||||
questions,
|
||||
userSolutions,
|
||||
updateIndex,
|
||||
onNext,
|
||||
onBack,
|
||||
}: MultipleChoiceExercise & CommonProps) {
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
export default function MultipleChoice({id, type, prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) {
|
||||
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||
|
||||
const calculateScore = () => {
|
||||
const total = questions.length;
|
||||
@@ -79,15 +85,11 @@ export default function MultipleChoice({
|
||||
return {total, correct, missing};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (updateIndex) updateIndex(questionIndex);
|
||||
}, [questionIndex, updateIndex]);
|
||||
|
||||
const next = () => {
|
||||
if (questionIndex === questions.length - 1) {
|
||||
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
||||
} else {
|
||||
setQuestionIndex((prev) => prev + 1);
|
||||
setQuestionIndex(questionIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -95,7 +97,7 @@ export default function MultipleChoice({
|
||||
if (questionIndex === 0) {
|
||||
onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type});
|
||||
} else {
|
||||
setQuestionIndex((prev) => prev - 1);
|
||||
setQuestionIndex(questionIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -126,11 +126,16 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex gap-4 px-1">
|
||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
|
||||
<div className="bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2" key={key}>
|
||||
{key}: Level {userSolutions[0].evaluation!.task_response[key]}
|
||||
</div>
|
||||
))}
|
||||
{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 className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2")} key={key}>
|
||||
{key}: Level {grade}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{userSolutions[0].evaluation &&
|
||||
(userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? (
|
||||
@@ -158,6 +163,17 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
}>
|
||||
Recommended Answer
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||
)
|
||||
}>
|
||||
Global Overview
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||
@@ -171,6 +187,23 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
||||
userSolutions[0].evaluation!.perfect_answer_1.replaceAll(/\s{2,}/g, "\n\n")}
|
||||
</span>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
||||
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
||||
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-2">
|
||||
<span className={"font-semibold"}>
|
||||
{key}: Level {grade}
|
||||
</span>
|
||||
{typeof taskResponse !== "number" && <span>{taskResponse.comment}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
) : (
|
||||
|
||||
@@ -7,11 +7,17 @@ import {Dialog, Tab, Transition} from "@headlessui/react";
|
||||
import {writingReverseMarking} from "@/utils/score";
|
||||
import clsx from "clsx";
|
||||
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) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [showDiff, setShowDiff] = useState(false);
|
||||
|
||||
const {user} = useUser();
|
||||
|
||||
const aiEval = userSolutions && userSolutions.length > 0 ? userSolutions[0].evaluation?.ai_detection : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{attachment && (
|
||||
@@ -117,11 +123,16 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex gap-4 px-1">
|
||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
|
||||
<div className="bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2" key={key}>
|
||||
{key}: Level {userSolutions[0].evaluation!.task_response[key]}
|
||||
</div>
|
||||
))}
|
||||
{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 className={clsx("bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2")} key={key}>
|
||||
{key}: Level {grade}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{userSolutions[0].evaluation && userSolutions[0].evaluation.perfect_answer ? (
|
||||
<Tab.Group>
|
||||
@@ -148,6 +159,30 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
||||
}>
|
||||
Recommended Answer
|
||||
</Tab>
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
|
||||
)
|
||||
}>
|
||||
Global Overview
|
||||
</Tab>
|
||||
{aiEval && user?.type !== "student" && (
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||
"transition duration-300 ease-in-out",
|
||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
|
||||
)
|
||||
}>
|
||||
AI Use
|
||||
</Tab>
|
||||
)}
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||
@@ -158,6 +193,28 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
||||
{userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n").replaceAll("\\n", "\n")}
|
||||
</span>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
||||
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
||||
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-2">
|
||||
<span className={"font-semibold"}>
|
||||
{key}: Level {grade}
|
||||
</span>
|
||||
{typeof taskResponse !== "number" && <span>{taskResponse.comment}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
{aiEval && user?.type !== "student" && (
|
||||
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||
<AIDetection {...aiEval} />
|
||||
</Tab.Panel>
|
||||
)}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
) : (
|
||||
|
||||
@@ -22,7 +22,6 @@ import Writing from "./Writing";
|
||||
const MatchSentences = dynamic(() => import("@/components/Solutions/MatchSentences"), {ssr: false});
|
||||
|
||||
export interface CommonProps {
|
||||
updateIndex?: (internalIndex: number) => void;
|
||||
onNext: (userSolutions: UserSolution) => void;
|
||||
onBack: (userSolutions: UserSolution) => void;
|
||||
}
|
||||
@@ -36,15 +35,7 @@ export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: (
|
||||
case "matchSentences":
|
||||
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "multipleChoice":
|
||||
return (
|
||||
<MultipleChoice
|
||||
key={exercise.id}
|
||||
{...(exercise as MultipleChoiceExercise)}
|
||||
updateIndex={updateIndex}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
return <MultipleChoice key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "writeBlanks":
|
||||
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "writing":
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,4 +10,4 @@
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-3ml0u%40storied-phalanx-349916.iam.gserviceaccount.com",
|
||||
"universe_domain": "googleapis.com"
|
||||
}
|
||||
}
|
||||
13
src/constants/staging.json
Normal file
13
src/constants/staging.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"type": "service_account",
|
||||
"project_id": "encoach-staging",
|
||||
"private_key_id": "5718a649419776df9637589f8696a258a6a70f6c",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC2C6Es2gY8lLvH\ndVilNtRNm9glSaPXMNw2PzZZbSGuG1uGPFaCzlq1lOb2u17YfMG4GriKIMjIQKXF\nqdvxA8CAmAFRuDjUGmpbO/X1ZW7amOs5Bjed2BYmL01dEqzzwwh7rEfNDjeghRPx\n1uKzH8A6TLT5xq+74I5K1CIgiljBpZimsERu2SDawjkdtZfA7qoylA46Nq66LuwQ\nVyv9CK2SZNpBcT3sunCmRsrCzmSTzKdbcqRPdqUKgZOH/Rjp0sw9VuUgwoxdGZV3\n5SJjObo5ceZ1OSiJm7GwLzp7uq16sqycgSYwppNLI5OtzOfSuWbGD4+a044t2Mlq\n9PHXv7H/AgMBAAECggEAAfhKlFwq8MaL6PggRJq9HbaKgQ4fcOmCmy8AQmPNF1UM\nyVKSKGndjxUfPLCWsaaunUnjZlHoKkvndKXxDyttuVaBE9EiWEqNjRLZ3KpuJ9Jm\nH+CtLbmUCnISQb1n1AlvvZAwhLZbLBL/PhYyWiLapybZAdJAaOWLVKGgBD8gVRQW\nJFCqnszX1O2YlpWHutb979R4qoY/XAf94gyMkTpXZwuETvFqZbau2vxRZ8qARix3\nmic881PwiF6Cod8UPCS9yMK+Q+Se6SomwXU9PCmlummn9xmQBAxYy8gIAVs/J9Fg\n5SvhnImAPDd+zIzzw2cHCiruNWIhroMVZDZJgWdY1QKBgQDjTKKeFOur3ijJJL2/\nWg1SE2jLP0GpXzM5YMx6jdOCNDCzugPngRucRXiTkJ2FnUgyMcQyi6hyrbWXN/6z\nXhx5fwLB4tnTcqOMvNfcay5mDk3RW9ZZJxayB54Sf1Nm/4xiDBnGPT+iHQvK+/pT\nwScWznFkmk60E796o76OLn3PEwKBgQDNCC2uPq+uOcCopIO8HH88gqdxTvpbeHUU\nrdJOmr1VtGNuvay/mfpva9+VEtGbZTFzjhfvfCEIjpj3Llh8Flb9EYa6BmscBiyp\ngszEeFuB3zHndlSCZPnGJ7JiRAdPAEgG3Gl/r9th6PDaEMq0MFS5i7GGhPBIRYCG\nUtmY5eVy5QKBgH5Nuls/YsnJFD7ZNLscziQadvPhvZnhNbSfjmBXaP2EBMAKEFtX\nCcGndN4C0RVLFbAWqWAw7LR0xGA4FEcVd5snsZ+Nb98oZ6sv0H9B67F4J1O7xXsa\n1mitBPBgYjbsr9RXxwa6SB7MJx5vMGXUAeWRZ78wY6V7B76dOKkHOo+TAoGBAJf5\nBOsPueZZFm2qK58GPGVcrsI0+StNuPLP+H+dANQC9mTCIMaQWmm2Oq5jmYwmUKZH\nX4R6rH2MPOOSrbGkWWwRTpyaX1ARX49xzVefoqw8BOB8/Bz+vYjcKcPeitBK9Bhp\nzaUAc4s6PzRTl/xBirtRSQ/df8ECC0cFKBbF6PHlAoGAGqnlpo+k8vAtg6ulCuGu\nx2Y/c5UmvXGHk60pccnW3UtENSDnl99OgMfBz8/qLAMWs6DUQ/kvSlHQPmMBHRWZ\nNTr6ceGXyNs4KdYoj1K7AU3c0Lm0wyQ2giQMoOOUQAm98Xr8z5aiihj10hHPmzzL\n9kwpOmZpjNmC/ERD69imWhY=\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "firebase-adminsdk-8rs9e@encoach-staging.iam.gserviceaccount.com",
|
||||
"client_id": "108221424237414412378",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-8rs9e%40encoach-staging.iam.gserviceaccount.com",
|
||||
"universe_domain": "googleapis.com"
|
||||
}
|
||||
@@ -1,39 +1,91 @@
|
||||
import {Type} from "@/interfaces/user";
|
||||
import { Type } from "@/interfaces/user";
|
||||
|
||||
export const PERMISSIONS = {
|
||||
generateCode: {
|
||||
student: ["corporate", "developer", "admin"],
|
||||
teacher: ["corporate", "developer", "admin"],
|
||||
corporate: ["admin", "developer"],
|
||||
admin: ["developer", "admin"],
|
||||
agent: ["developer", "admin"],
|
||||
developer: ["developer"],
|
||||
},
|
||||
deleteUser: {
|
||||
student: ["corporate", "developer", "admin"],
|
||||
teacher: ["corporate", "developer", "admin"],
|
||||
corporate: ["admin", "developer"],
|
||||
admin: ["developer", "admin"],
|
||||
agent: ["developer", "admin"],
|
||||
developer: ["developer"],
|
||||
},
|
||||
updateUser: {
|
||||
student: ["developer", "admin"],
|
||||
teacher: ["developer", "admin"],
|
||||
corporate: ["admin", "developer"],
|
||||
admin: ["developer", "admin"],
|
||||
agent: ["developer", "admin"],
|
||||
developer: ["developer"],
|
||||
},
|
||||
updateExpiryDate: {
|
||||
student: ["developer", "admin"],
|
||||
teacher: ["developer", "admin"],
|
||||
corporate: ["admin", "developer"],
|
||||
admin: ["developer", "admin"],
|
||||
agent: ["developer", "admin"],
|
||||
developer: ["developer"],
|
||||
},
|
||||
examManagement: {
|
||||
delete: ["developer", "admin"],
|
||||
},
|
||||
generateCode: {
|
||||
student: ["corporate", "developer", "admin", "mastercorporate"],
|
||||
teacher: ["corporate", "developer", "admin", "mastercorporate"],
|
||||
corporate: ["admin", "developer"],
|
||||
mastercorporate: ["admin", "developer"],
|
||||
|
||||
admin: ["developer", "admin"],
|
||||
agent: ["developer", "admin"],
|
||||
developer: ["developer"],
|
||||
},
|
||||
deleteUser: {
|
||||
student: {
|
||||
perm: "deleteStudent",
|
||||
list: ["corporate", "developer", "admin", "mastercorporate"],
|
||||
},
|
||||
teacher: {
|
||||
perm: "deleteTeacher",
|
||||
list: ["corporate", "developer", "admin", "mastercorporate"],
|
||||
},
|
||||
corporate: {
|
||||
perm: "deleteCorporate",
|
||||
list: ["admin", "developer"],
|
||||
},
|
||||
mastercorporate: {
|
||||
perm: undefined,
|
||||
list: ["admin", "developer"],
|
||||
},
|
||||
|
||||
admin: {
|
||||
perm: "deleteAdmin",
|
||||
list: ["developer", "admin"],
|
||||
},
|
||||
agent: {
|
||||
perm: "deleteCountryManager",
|
||||
list: ["developer", "admin"],
|
||||
},
|
||||
developer: {
|
||||
perm: undefined,
|
||||
list: ["developer"],
|
||||
},
|
||||
},
|
||||
updateUser: {
|
||||
student: {
|
||||
perm: "editStudent",
|
||||
list: ["developer", "admin"],
|
||||
},
|
||||
teacher: {
|
||||
perm: "editTeacher",
|
||||
list: ["developer", "admin"],
|
||||
},
|
||||
|
||||
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 useStats from "@/hooks/useStats";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import { User} from "@/interfaces/user";
|
||||
import { User } from "@/interfaces/user";
|
||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||
import {dateSorter} from "@/utils";
|
||||
import { dateSorter } from "@/utils";
|
||||
import moment from "moment";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsArrowLeft, BsPersonFill, BsBank, BsCurrencyDollar} from "react-icons/bs";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
BsArrowLeft,
|
||||
BsPersonFill,
|
||||
BsBank,
|
||||
BsCurrencyDollar,
|
||||
} from "react-icons/bs";
|
||||
import UserCard from "@/components/UserCard";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
|
||||
import IconCard from "./IconCard";
|
||||
import usePaymentStatusUsers from '@/hooks/usePaymentStatusUsers';
|
||||
import usePaymentStatusUsers from "@/hooks/usePaymentStatusUsers";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default function AgentDashboard({user}: Props) {
|
||||
const [page, setPage] = useState("");
|
||||
const [selectedUser, setSelectedUser] = useState<User>();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
export default function AgentDashboard({ user }: Props) {
|
||||
const [page, setPage] = useState("");
|
||||
const [selectedUser, setSelectedUser] = useState<User>();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const {stats} = useStats();
|
||||
const {users, reload} = useUsers();
|
||||
const {groups} = useGroups(user.id);
|
||||
const { pending, done } = usePaymentStatusUsers();
|
||||
const { stats } = useStats();
|
||||
const { users, reload } = useUsers();
|
||||
const { groups } = useGroups(user.id);
|
||||
const { pending, done } = usePaymentStatusUsers();
|
||||
|
||||
useEffect(() => {
|
||||
setShowModal(!!selectedUser && page === "");
|
||||
}, [selectedUser, page]);
|
||||
useEffect(() => {
|
||||
setShowModal(!!selectedUser && page === "");
|
||||
}, [selectedUser, page]);
|
||||
|
||||
const corporateFilter = (user: User) => user.type === "corporate";
|
||||
const referredCorporateFilter = (x: User) =>
|
||||
x.type === "corporate" && !!x.corporateInformation && x.corporateInformation.referralAgent === user.id;
|
||||
const inactiveReferredCorporateFilter = (x: User) =>
|
||||
referredCorporateFilter(x) && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
||||
const corporateFilter = (user: User) => user.type === "corporate";
|
||||
const referredCorporateFilter = (x: User) =>
|
||||
x.type === "corporate" &&
|
||||
!!x.corporateInformation &&
|
||||
x.corporateInformation.referralAgent === user.id;
|
||||
const inactiveReferredCorporateFilter = (x: User) =>
|
||||
referredCorporateFilter(x) &&
|
||||
(x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
||||
|
||||
const UserDisplay = ({ displayUser, allowClick = true }: {displayUser: User, allowClick?: boolean}) => (
|
||||
<div
|
||||
onClick={() => allowClick && setSelectedUser(displayUser)}
|
||||
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
||||
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<span>
|
||||
{displayUser.type === "corporate"
|
||||
? displayUser.corporateInformation?.companyInformation?.name || displayUser.name
|
||||
: displayUser.name}
|
||||
</span>
|
||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const UserDisplay = ({
|
||||
displayUser,
|
||||
allowClick = true,
|
||||
}: {
|
||||
displayUser: User;
|
||||
allowClick?: boolean;
|
||||
}) => (
|
||||
<div
|
||||
onClick={() => allowClick && setSelectedUser(displayUser)}
|
||||
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"
|
||||
>
|
||||
<img
|
||||
src={displayUser.profilePicture}
|
||||
alt={displayUser.name}
|
||||
className="rounded-full w-10 h-10"
|
||||
/>
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<span>
|
||||
{displayUser.type === "corporate"
|
||||
? displayUser.corporateInformation?.companyInformation?.name ||
|
||||
displayUser.name
|
||||
: displayUser.name}
|
||||
</span>
|
||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ReferredCorporateList = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Referred Corporate ({users.filter(referredCorporateFilter).length})</h2>
|
||||
</div>
|
||||
const ReferredCorporateList = () => {
|
||||
return (
|
||||
<UserList
|
||||
user={user}
|
||||
filters={[referredCorporateFilter]}
|
||||
renderHeader={(total) => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||
>
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">
|
||||
Referred Corporate ({total})
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
<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 = () => {
|
||||
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">Inactive Referred Corporate ({users.filter(inactiveReferredCorporateFilter).length})</h2>
|
||||
</div>
|
||||
const CorporateList = () => {
|
||||
const filter = (x: User) => x.type === "corporate";
|
||||
|
||||
<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 filter = (x: User) => x.type === "corporate";
|
||||
const CorporatePaidStatusList = ({ paid }: { paid: Boolean }) => {
|
||||
const list = paid ? done : pending;
|
||||
const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Corporate ({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">
|
||||
{paid ? "Payment Done" : "Pending Payment"} ({total})
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const CorporatePaidStatusList = ({ paid }: {paid: Boolean}) => {
|
||||
const list = paid ? done : pending;
|
||||
const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
|
||||
const DefaultDashboard = () => (
|
||||
<>
|
||||
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:gap-4 text-center">
|
||||
<IconCard
|
||||
onClick={() => setPage("referredCorporate")}
|
||||
Icon={BsBank}
|
||||
label="Referred Corporate"
|
||||
value={users.filter(referredCorporateFilter).length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("inactiveReferredCorporate")}
|
||||
Icon={BsBank}
|
||||
label="Inactive Referred Corporate"
|
||||
value={users.filter(inactiveReferredCorporateFilter).length}
|
||||
color="rose"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("corporate")}
|
||||
Icon={BsBank}
|
||||
label="Corporate"
|
||||
value={users.filter(corporateFilter).length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("paymentdone")}
|
||||
Icon={BsCurrencyDollar}
|
||||
label="Payment Done"
|
||||
value={done.length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("paymentpending")}
|
||||
Icon={BsCurrencyDollar}
|
||||
label="Pending Payment"
|
||||
value={pending.length}
|
||||
color="rose"
|
||||
/>
|
||||
</section>
|
||||
|
||||
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">{paid ? 'Payment Done' : 'Pending Payment'} ({list.length})</h2>
|
||||
</div>
|
||||
<UserList user={user} filters={[filter]} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
const DefaultDashboard = () => (
|
||||
<>
|
||||
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:gap-4 text-center">
|
||||
<IconCard
|
||||
onClick={() => setPage("referredCorporate")}
|
||||
Icon={BsBank}
|
||||
label="Referred Corporate"
|
||||
value={users.filter(referredCorporateFilter).length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("inactiveReferredCorporate")}
|
||||
Icon={BsBank}
|
||||
label="Inactive Referred Corporate"
|
||||
value={users.filter(inactiveReferredCorporateFilter).length}
|
||||
color="rose"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("corporate")}
|
||||
Icon={BsBank}
|
||||
label="Corporate"
|
||||
value={users.filter(corporateFilter).length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("paymentdone")}
|
||||
Icon={BsCurrencyDollar}
|
||||
label="Payment Done"
|
||||
value={done.length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("paymentpending")}
|
||||
Icon={BsCurrencyDollar}
|
||||
label="Pending Payment"
|
||||
value={pending.length}
|
||||
color="rose"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Latest Referred Corporate</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(referredCorporateFilter)
|
||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} displayUser={x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Latest corporate</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(corporateFilter)
|
||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} displayUser={x} allowClick={false} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Referenced corporate expiring in 1 month</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(
|
||||
(x) =>
|
||||
referredCorporateFilter(x) &&
|
||||
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
|
||||
moment().isBefore(moment(x.subscriptionExpirationDate)),
|
||||
)
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} displayUser={x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
|
||||
<>
|
||||
{selectedUser && (
|
||||
<div className="w-full flex flex-col gap-8">
|
||||
<UserCard
|
||||
loggedInUser={user}
|
||||
onClose={(shouldReload) => {
|
||||
setSelectedUser(undefined);
|
||||
if (shouldReload) reload();
|
||||
}}
|
||||
onViewStudents={
|
||||
selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
|
||||
}
|
||||
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
|
||||
user={selectedUser}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Modal>
|
||||
{page === "referredCorporate" && <ReferredCorporateList />}
|
||||
{page === "corporate" && <CorporateList />}
|
||||
{page === "inactiveReferredCorporate" && <InactiveReferredCorporateList />}
|
||||
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
|
||||
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
|
||||
{page === "" && <DefaultDashboard />}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
|
||||
<>
|
||||
{selectedUser && (
|
||||
<div className="w-full flex flex-col gap-8">
|
||||
<UserCard
|
||||
loggedInUser={user}
|
||||
onClose={(shouldReload) => {
|
||||
setSelectedUser(undefined);
|
||||
if (shouldReload) reload();
|
||||
}}
|
||||
onViewStudents={
|
||||
selectedUser.type === "corporate" ||
|
||||
selectedUser.type === "teacher"
|
||||
? () => setPage("students")
|
||||
: undefined
|
||||
}
|
||||
onViewTeachers={
|
||||
selectedUser.type === "corporate"
|
||||
? () => setPage("teachers")
|
||||
: undefined
|
||||
}
|
||||
user={selectedUser}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Modal>
|
||||
{page === "referredCorporate" && <ReferredCorporateList />}
|
||||
{page === "corporate" && <CorporateList />}
|
||||
{page === "inactiveReferredCorporate" && (
|
||||
<InactiveReferredCorporateList />
|
||||
)}
|
||||
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
|
||||
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
|
||||
{page === "" && <DefaultDashboard />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import Input from "@/components/Low/Input";
|
||||
import Modal from "@/components/Modal";
|
||||
import {Module} from "@/interfaces";
|
||||
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 {generate} from "random-words";
|
||||
import {capitalize} from "lodash";
|
||||
@@ -16,11 +16,11 @@ import moment from "moment";
|
||||
import axios from "axios";
|
||||
import {getExam} from "@/utils/exams";
|
||||
import {toast} from "react-toastify";
|
||||
import {uuidv4} from "@firebase/util";
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
import {InstructorGender, Variant} from "@/interfaces/exam";
|
||||
import Select from "@/components/Low/Select";
|
||||
import useExams from "@/hooks/useExams";
|
||||
|
||||
interface Props {
|
||||
isCreating: boolean;
|
||||
@@ -44,6 +44,14 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied");
|
||||
// creates a new exam for each assignee or just one exam for all assignees
|
||||
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
|
||||
const [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 modules = selectedModules.filter((x) => x !== module);
|
||||
@@ -61,6 +69,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
assignees,
|
||||
name,
|
||||
startDate,
|
||||
examIDs: !useRandomExams ? examIDs : undefined,
|
||||
endDate,
|
||||
selectedModules,
|
||||
generateMultiple,
|
||||
@@ -229,19 +238,52 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor's Gender</label>
|
||||
<Select
|
||||
value={{value: instructorGender, label: capitalize(instructorGender)}}
|
||||
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)}
|
||||
disabled={!selectedModules.includes("speaking") || !!assignment}
|
||||
options={[
|
||||
{value: "male", label: "Male"},
|
||||
{value: "female", label: "Female"},
|
||||
{value: "varied", label: "Varied"},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{selectedModules.includes("speaking") && (
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor's Gender</label>
|
||||
<Select
|
||||
value={{value: instructorGender, label: capitalize(instructorGender)}}
|
||||
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)}
|
||||
disabled={!selectedModules.includes("speaking") || !!assignment}
|
||||
options={[
|
||||
{value: "male", label: "Male"},
|
||||
{value: "female", label: "Female"},
|
||||
{value: "varied", label: "Varied"},
|
||||
]}
|
||||
/>
|
||||
</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">
|
||||
<span className="font-semibold">Assignees ({assignees.length} selected)</span>
|
||||
@@ -323,7 +365,14 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
</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]"
|
||||
onClick={createAssignment}
|
||||
isLoading={isLoading}>
|
||||
|
||||
@@ -2,325 +2,412 @@
|
||||
import Modal from "@/components/Modal";
|
||||
import useStats from "@/hooks/useStats";
|
||||
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 {dateSorter} from "@/utils";
|
||||
import { dateSorter } from "@/utils";
|
||||
import moment from "moment";
|
||||
import {useEffect, useState} from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
BsArrowLeft,
|
||||
BsClipboard2Data,
|
||||
BsClipboard2DataFill,
|
||||
BsClock,
|
||||
BsGlobeCentralSouthAsia,
|
||||
BsPaperclip,
|
||||
BsPerson,
|
||||
BsPersonAdd,
|
||||
BsPersonFill,
|
||||
BsPersonFillGear,
|
||||
BsPersonGear,
|
||||
BsPencilSquare,
|
||||
BsPersonBadge,
|
||||
BsPersonCheck,
|
||||
BsPeople,
|
||||
BsArrowLeft,
|
||||
BsClipboard2Data,
|
||||
BsClipboard2DataFill,
|
||||
BsClock,
|
||||
BsGlobeCentralSouthAsia,
|
||||
BsPaperclip,
|
||||
BsPerson,
|
||||
BsPersonAdd,
|
||||
BsPersonFill,
|
||||
BsPersonFillGear,
|
||||
BsPersonGear,
|
||||
BsPencilSquare,
|
||||
BsPersonBadge,
|
||||
BsPersonCheck,
|
||||
BsPeople,
|
||||
} from "react-icons/bs";
|
||||
import UserCard from "@/components/UserCard";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
|
||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||
import {Module} from "@/interfaces";
|
||||
import {groupByExam} from "@/utils/stats";
|
||||
import { 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 { useRouter } from "next/router";
|
||||
import useCodes from "@/hooks/useCodes";
|
||||
import { getUserCorporate } from "@/utils/groups";
|
||||
|
||||
interface Props {
|
||||
user: CorporateUser;
|
||||
user: CorporateUser;
|
||||
}
|
||||
|
||||
export default function CorporateDashboard({user}: Props) {
|
||||
const [page, setPage] = useState("");
|
||||
const [selectedUser, setSelectedUser] = useState<User>();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
export default function CorporateDashboard({ user }: Props) {
|
||||
const [page, setPage] = useState("");
|
||||
const [selectedUser, setSelectedUser] = useState<User>();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [corporateUserToShow, setCorporateUserToShow] =
|
||||
useState<CorporateUser>();
|
||||
|
||||
const {stats} = useStats();
|
||||
const {users, reload} = useUsers();
|
||||
const {codes} = useCodes(user.id);
|
||||
const {groups} = useGroups(user.id);
|
||||
const { stats } = useStats();
|
||||
const { users, reload } = useUsers();
|
||||
const { codes } = useCodes(user.id);
|
||||
const { groups } = useGroups(user.id);
|
||||
|
||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||
const router = useRouter();
|
||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
setShowModal(!!selectedUser && page === "");
|
||||
}, [selectedUser, page]);
|
||||
useEffect(() => {
|
||||
setShowModal(!!selectedUser && page === "");
|
||||
}, [selectedUser, page]);
|
||||
|
||||
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);
|
||||
useEffect(() => {
|
||||
// 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) => (
|
||||
<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 getStatsByStudent = (user: User) =>
|
||||
stats.filter((s) => s.user === user.id);
|
||||
|
||||
const StudentsList = () => {
|
||||
const filter = (x: User) =>
|
||||
x.type === "student" &&
|
||||
(!!selectedUser
|
||||
? groups
|
||||
.filter((g) => g.admin === selectedUser.id)
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id) || false
|
||||
: groups.flatMap((g) => g.participants).includes(x.id));
|
||||
const 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>
|
||||
);
|
||||
|
||||
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">Students ({users.filter(filter).length})</h2>
|
||||
</div>
|
||||
const StudentsList = () => {
|
||||
const filter = (x: User) =>
|
||||
x.type === "student" &&
|
||||
(!!selectedUser
|
||||
? groups
|
||||
.filter((g) => g.admin === selectedUser.id)
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id) || false
|
||||
: groups.flatMap((g) => g.participants).includes(x.id));
|
||||
|
||||
<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 filter = (x: User) =>
|
||||
x.type === "teacher" &&
|
||||
(!!selectedUser
|
||||
? groups
|
||||
.filter((g) => g.admin === selectedUser.id)
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id) || false
|
||||
: groups.flatMap((g) => g.participants).includes(x.id));
|
||||
const TeachersList = () => {
|
||||
const filter = (x: User) =>
|
||||
x.type === "teacher" &&
|
||||
(!!selectedUser
|
||||
? groups
|
||||
.filter((g) => g.admin === selectedUser.id)
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id) || false
|
||||
: groups.flatMap((g) => g.participants).includes(x.id));
|
||||
|
||||
return (
|
||||
<>
|
||||
<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 ({users.filter(filter).length})</h2>
|
||||
</div>
|
||||
return (
|
||||
<UserList
|
||||
user={user}
|
||||
filters={[filter]}
|
||||
renderHeader={(total) => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||
>
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">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 = () => {
|
||||
const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id);
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||
>
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">
|
||||
Groups ({groups.filter(filter).length})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Groups ({groups.filter(filter).length})</h2>
|
||||
</div>
|
||||
<GroupList user={user} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
<GroupList user={user} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
const averageLevelCalculator = (studentStats: Stat[]) => {
|
||||
const formattedStats = studentStats
|
||||
.map((s) => ({
|
||||
focus: users.find((u) => u.id === s.user)?.focus,
|
||||
score: s.score,
|
||||
module: s.module,
|
||||
}))
|
||||
.filter((f) => !!f.focus);
|
||||
const bandScores = formattedStats.map((s) => ({
|
||||
module: s.module,
|
||||
level: calculateBandScore(
|
||||
s.score.correct,
|
||||
s.score.total,
|
||||
s.module,
|
||||
s.focus!
|
||||
),
|
||||
}));
|
||||
|
||||
const averageLevelCalculator = (studentStats: Stat[]) => {
|
||||
const formattedStats = studentStats
|
||||
.map((s) => ({focus: 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));
|
||||
|
||||
const levels: {[key in Module]: number} = {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0};
|
||||
bandScores.forEach((b) => (levels[b.module] += b.level));
|
||||
return calculateAverageLevel(levels);
|
||||
};
|
||||
|
||||
return calculateAverageLevel(levels);
|
||||
};
|
||||
const 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="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center">
|
||||
<IconCard
|
||||
onClick={() => setPage("students")}
|
||||
Icon={BsPersonFill}
|
||||
label="Students"
|
||||
value={users.filter(studentFilter).length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
onClick={() => setPage("teachers")}
|
||||
Icon={BsPencilSquare}
|
||||
label="Teachers"
|
||||
value={users.filter(teacherFilter).length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsClipboard2Data}
|
||||
label="Exams Performed"
|
||||
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPaperclip}
|
||||
label="Average Level"
|
||||
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard onClick={() => setPage("groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
|
||||
<IconCard
|
||||
Icon={BsPersonCheck}
|
||||
label="User Balance"
|
||||
value={`${codes.length}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsClock}
|
||||
label="Expiration Date"
|
||||
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
|
||||
color="rose"
|
||||
/>
|
||||
</section>
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Latest students</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(studentFilter)
|
||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Latest teachers</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(teacherFilter)
|
||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Highest level students</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(studentFilter)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
calculateAverageLevel(b.levels) -
|
||||
calculateAverageLevel(a.levels)
|
||||
)
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Highest exam count students</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(studentFilter)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Object.keys(groupByExam(getStatsByStudent(b))).length -
|
||||
Object.keys(groupByExam(getStatsByStudent(a))).length
|
||||
)
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
<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),
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
onViewTeachers={
|
||||
selectedUser.type === "corporate" || selectedUser.type === "student"
|
||||
? () => {
|
||||
appendUserFilters({
|
||||
id: "view-teachers",
|
||||
filter: (x: User) => x.type === "teacher",
|
||||
});
|
||||
appendUserFilters({
|
||||
id: "belongs-to-admin",
|
||||
filter: (x: User) =>
|
||||
groups
|
||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id),
|
||||
});
|
||||
|
||||
router.push("/list/users");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
user={selectedUser}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Modal>
|
||||
{page === "students" && <StudentsList />}
|
||||
{page === "teachers" && <TeachersList />}
|
||||
{page === "groups" && <GroupsList />}
|
||||
{page === "" && <DefaultDashboard />}
|
||||
</>
|
||||
);
|
||||
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 ProgressBar from "@/components/Low/ProgressBar";
|
||||
import InviteCard from "@/components/Medium/InviteCard";
|
||||
import PayPalPayment from "@/components/PayPalPayment";
|
||||
import ProfileSummary from "@/components/ProfileSummary";
|
||||
import useAssignments from "@/hooks/useAssignments";
|
||||
import useInvites from "@/hooks/useInvites";
|
||||
|
||||
@@ -2,380 +2,477 @@
|
||||
import Modal from "@/components/Modal";
|
||||
import useStats from "@/hooks/useStats";
|
||||
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 {dateSorter} from "@/utils";
|
||||
import { dateSorter } from "@/utils";
|
||||
import moment from "moment";
|
||||
import {useEffect, useState} from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
BsArrowLeft,
|
||||
BsArrowRepeat,
|
||||
BsClipboard2Data,
|
||||
BsClipboard2DataFill,
|
||||
BsClipboard2Heart,
|
||||
BsClipboard2X,
|
||||
BsClipboardPulse,
|
||||
BsClock,
|
||||
BsEnvelopePaper,
|
||||
BsGlobeCentralSouthAsia,
|
||||
BsPaperclip,
|
||||
BsPeople,
|
||||
BsPerson,
|
||||
BsPersonAdd,
|
||||
BsPersonFill,
|
||||
BsPersonFillGear,
|
||||
BsPersonGear,
|
||||
BsPlus,
|
||||
BsRepeat,
|
||||
BsRepeat1,
|
||||
BsArrowLeft,
|
||||
BsArrowRepeat,
|
||||
BsClipboard2Data,
|
||||
BsClipboard2DataFill,
|
||||
BsClipboard2Heart,
|
||||
BsClipboard2X,
|
||||
BsClipboardPulse,
|
||||
BsClock,
|
||||
BsEnvelopePaper,
|
||||
BsGlobeCentralSouthAsia,
|
||||
BsPaperclip,
|
||||
BsPeople,
|
||||
BsPerson,
|
||||
BsPersonAdd,
|
||||
BsPersonFill,
|
||||
BsPersonFillGear,
|
||||
BsPersonGear,
|
||||
BsPlus,
|
||||
BsRepeat,
|
||||
BsRepeat1,
|
||||
} from "react-icons/bs";
|
||||
import UserCard from "@/components/UserCard";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
|
||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||
import {Module} from "@/interfaces";
|
||||
import {groupByExam} from "@/utils/stats";
|
||||
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
|
||||
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
||||
import { Module } from "@/interfaces";
|
||||
import { groupByExam } from "@/utils/stats";
|
||||
import IconCard from "./IconCard";
|
||||
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
||||
import useAssignments from "@/hooks/useAssignments";
|
||||
import {Assignment} from "@/interfaces/results";
|
||||
import { Assignment } from "@/interfaces/results";
|
||||
import AssignmentCard from "./AssignmentCard";
|
||||
import Button from "@/components/Low/Button";
|
||||
import clsx from "clsx";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import AssignmentCreator from "./AssignmentCreator";
|
||||
import AssignmentView from "./AssignmentView";
|
||||
import {getUserCorporate} from "@/utils/groups";
|
||||
import { getUserCorporate } from "@/utils/groups";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default function TeacherDashboard({user}: Props) {
|
||||
const [page, setPage] = useState("");
|
||||
const [selectedUser, setSelectedUser] = useState<User>();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
|
||||
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
||||
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
|
||||
export default function TeacherDashboard({ user }: Props) {
|
||||
const [page, setPage] = useState("");
|
||||
const [selectedUser, setSelectedUser] = useState<User>();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
|
||||
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
||||
const [corporateUserToShow, setCorporateUserToShow] =
|
||||
useState<CorporateUser>();
|
||||
|
||||
const {stats} = useStats();
|
||||
const {users, reload} = useUsers();
|
||||
const {groups} = useGroups(user.id);
|
||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id});
|
||||
const { stats } = useStats();
|
||||
const { users, reload } = useUsers();
|
||||
const { groups } = useGroups(user.id);
|
||||
const {
|
||||
assignments,
|
||||
isLoading: isAssignmentsLoading,
|
||||
reload: reloadAssignments,
|
||||
} = useAssignments({ assigner: user.id });
|
||||
|
||||
useEffect(() => {
|
||||
setShowModal(!!selectedUser && page === "");
|
||||
}, [selectedUser, page]);
|
||||
useEffect(() => {
|
||||
setShowModal(!!selectedUser && page === "");
|
||||
}, [selectedUser, page]);
|
||||
|
||||
useEffect(() => {
|
||||
getUserCorporate(user.id).then(setCorporateUserToShow);
|
||||
}, [user]);
|
||||
useEffect(() => {
|
||||
getUserCorporate(user.id).then(setCorporateUserToShow);
|
||||
}, [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) => (
|
||||
<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 UserDisplay = (displayUser: User) => (
|
||||
<div
|
||||
onClick={() => setSelectedUser(displayUser)}
|
||||
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"
|
||||
>
|
||||
<img
|
||||
src={displayUser.profilePicture}
|
||||
alt={displayUser.name}
|
||||
className="rounded-full w-10 h-10"
|
||||
/>
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<span>{displayUser.name}</span>
|
||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const StudentsList = () => {
|
||||
const filter = (x: User) =>
|
||||
x.type === "student" &&
|
||||
(!!selectedUser
|
||||
? groups
|
||||
.filter((g) => g.admin === selectedUser.id)
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id) || false
|
||||
: groups.flatMap((g) => g.participants).includes(x.id));
|
||||
const StudentsList = () => {
|
||||
const filter = (x: User) =>
|
||||
x.type === "student" &&
|
||||
(!!selectedUser
|
||||
? groups
|
||||
.filter((g) => g.admin === selectedUser.id)
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id) || false
|
||||
: groups.flatMap((g) => g.participants).includes(x.id));
|
||||
|
||||
return (
|
||||
<>
|
||||
<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 ({users.filter(filter).length})</h2>
|
||||
</div>
|
||||
return (
|
||||
<UserList
|
||||
user={user}
|
||||
filters={[filter]}
|
||||
renderHeader={(total) => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||
>
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">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 = () => {
|
||||
const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id);
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||
>
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">
|
||||
Groups ({groups.filter(filter).length})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold">Groups ({groups.filter(filter).length})</h2>
|
||||
</div>
|
||||
<GroupList user={user} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
<GroupList user={user} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
const averageLevelCalculator = (studentStats: Stat[]) => {
|
||||
const formattedStats = studentStats
|
||||
.map((s) => ({
|
||||
focus: users.find((u) => u.id === s.user)?.focus,
|
||||
score: s.score,
|
||||
module: s.module,
|
||||
}))
|
||||
.filter((f) => !!f.focus);
|
||||
const bandScores = formattedStats.map((s) => ({
|
||||
module: s.module,
|
||||
level: calculateBandScore(
|
||||
s.score.correct,
|
||||
s.score.total,
|
||||
s.module,
|
||||
s.focus!
|
||||
),
|
||||
}));
|
||||
|
||||
const averageLevelCalculator = (studentStats: Stat[]) => {
|
||||
const formattedStats = studentStats
|
||||
.map((s) => ({focus: 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));
|
||||
|
||||
const levels: {[key in Module]: number} = {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0};
|
||||
bandScores.forEach((b) => (levels[b.module] += b.level));
|
||||
return calculateAverageLevel(levels);
|
||||
};
|
||||
|
||||
return calculateAverageLevel(levels);
|
||||
};
|
||||
const AssignmentsPage = () => {
|
||||
const activeFilter = (a: Assignment) =>
|
||||
moment(a.endDate).isAfter(moment()) &&
|
||||
moment(a.startDate).isBefore(moment()) &&
|
||||
a.assignees.length > a.results.length;
|
||||
const pastFilter = (a: Assignment) =>
|
||||
(moment(a.endDate).isBefore(moment()) ||
|
||||
a.assignees.length === a.results.length) &&
|
||||
!a.archived;
|
||||
const archivedFilter = (a: Assignment) => a.archived;
|
||||
const futureFilter = (a: Assignment) =>
|
||||
moment(a.startDate).isAfter(moment());
|
||||
|
||||
const AssignmentsPage = () => {
|
||||
const activeFilter = (a: Assignment) =>
|
||||
moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length;
|
||||
const pastFilter = (a: Assignment) => (moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length) && !a.archived;
|
||||
const archivedFilter = (a: Assignment) => a.archived;
|
||||
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
|
||||
return (
|
||||
<>
|
||||
<AssignmentView
|
||||
isOpen={!!selectedAssignment && !isCreatingAssignment}
|
||||
onClose={() => {
|
||||
setSelectedAssignment(undefined);
|
||||
setIsCreatingAssignment(false);
|
||||
reloadAssignments();
|
||||
}}
|
||||
assignment={selectedAssignment}
|
||||
/>
|
||||
<AssignmentCreator
|
||||
assignment={selectedAssignment}
|
||||
groups={groups.filter(
|
||||
(x) => x.admin === user.id || x.participants.includes(user.id)
|
||||
)}
|
||||
users={users.filter(
|
||||
(x) =>
|
||||
x.type === "student" &&
|
||||
(!!selectedUser
|
||||
? groups
|
||||
.filter((g) => g.admin === selectedUser.id)
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id) || false
|
||||
: groups.flatMap((g) => g.participants).includes(x.id))
|
||||
)}
|
||||
assigner={user.id}
|
||||
isCreating={isCreatingAssignment}
|
||||
cancelCreation={() => {
|
||||
setIsCreatingAssignment(false);
|
||||
setSelectedAssignment(undefined);
|
||||
reloadAssignments();
|
||||
}}
|
||||
/>
|
||||
<div className="w-full flex justify-between items-center">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||
>
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<div
|
||||
onClick={reloadAssignments}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||
>
|
||||
<span>Reload</span>
|
||||
<BsArrowRepeat
|
||||
className={clsx(
|
||||
"text-xl",
|
||||
isAssignmentsLoading && "animate-spin"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-2xl font-semibold">
|
||||
Active Assignments ({assignments.filter(activeFilter).length})
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{assignments.filter(activeFilter).map((a) => (
|
||||
<AssignmentCard
|
||||
{...a}
|
||||
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 (
|
||||
<>
|
||||
<AssignmentView
|
||||
isOpen={!!selectedAssignment && !isCreatingAssignment}
|
||||
onClose={() => {
|
||||
setSelectedAssignment(undefined);
|
||||
setIsCreatingAssignment(false);
|
||||
reloadAssignments();
|
||||
}}
|
||||
assignment={selectedAssignment}
|
||||
/>
|
||||
<AssignmentCreator
|
||||
assignment={selectedAssignment}
|
||||
groups={groups.filter((x) => x.admin === user.id || x.participants.includes(user.id))}
|
||||
users={users.filter(
|
||||
(x) =>
|
||||
x.type === "student" &&
|
||||
(!!selectedUser
|
||||
? groups
|
||||
.filter((g) => g.admin === selectedUser.id)
|
||||
.flatMap((g) => g.participants)
|
||||
.includes(x.id) || false
|
||||
: groups.flatMap((g) => g.participants).includes(x.id)),
|
||||
)}
|
||||
assigner={user.id}
|
||||
isCreating={isCreatingAssignment}
|
||||
cancelCreation={() => {
|
||||
setIsCreatingAssignment(false);
|
||||
setSelectedAssignment(undefined);
|
||||
reloadAssignments();
|
||||
}}
|
||||
/>
|
||||
<div className="w-full flex justify-between items-center">
|
||||
<div
|
||||
onClick={() => setPage("")}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<BsArrowLeft className="text-xl" />
|
||||
<span>Back</span>
|
||||
</div>
|
||||
<div
|
||||
onClick={reloadAssignments}
|
||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||
<span>Reload</span>
|
||||
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
|
||||
</div>
|
||||
</div>
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeFilter).length})</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{assignments.filter(activeFilter).map((a) => (
|
||||
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureFilter).length})</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div
|
||||
onClick={() => setIsCreatingAssignment(true)}
|
||||
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
|
||||
<BsPlus className="text-6xl" />
|
||||
<span className="text-lg">New Assignment</span>
|
||||
</div>
|
||||
{assignments.filter(futureFilter).map((a) => (
|
||||
<AssignmentCard
|
||||
{...a}
|
||||
onClick={() => {
|
||||
setSelectedAssignment(a);
|
||||
setIsCreatingAssignment(true);
|
||||
}}
|
||||
key={a.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{assignments.filter(pastFilter).map((a) => (
|
||||
<AssignmentCard
|
||||
{...a}
|
||||
onClick={() => setSelectedAssignment(a)}
|
||||
key={a.id}
|
||||
allowDownload
|
||||
reload={reloadAssignments}
|
||||
allowArchive
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-2xl font-semibold">Archived Assignments ({assignments.filter(archivedFilter).length})</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{assignments.filter(archivedFilter).map((a) => (
|
||||
<AssignmentCard
|
||||
{...a}
|
||||
onClick={() => setSelectedAssignment(a)}
|
||||
key={a.id}
|
||||
allowDownload
|
||||
reload={reloadAssignments}
|
||||
allowUnarchive
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
const DefaultDashboard = () => (
|
||||
<>
|
||||
{corporateUserToShow && (
|
||||
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
|
||||
Linked to:{" "}
|
||||
<b>
|
||||
{corporateUserToShow?.corporateInformation?.companyInformation
|
||||
.name || corporateUserToShow.name}
|
||||
</b>
|
||||
</div>
|
||||
)}
|
||||
<section
|
||||
className={clsx(
|
||||
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center",
|
||||
!!corporateUserToShow && "mt-12 xl:mt-6"
|
||||
)}
|
||||
>
|
||||
<IconCard
|
||||
onClick={() => setPage("students")}
|
||||
Icon={BsPersonFill}
|
||||
label="Students"
|
||||
value={users.filter(studentFilter).length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsClipboard2Data}
|
||||
label="Exams Performed"
|
||||
value={
|
||||
stats.filter((s) =>
|
||||
groups.flatMap((g) => g.participants).includes(s.user)
|
||||
).length
|
||||
}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPaperclip}
|
||||
label="Average Level"
|
||||
value={averageLevelCalculator(
|
||||
stats.filter((s) =>
|
||||
groups.flatMap((g) => g.participants).includes(s.user)
|
||||
)
|
||||
).toFixed(1)}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPeople}
|
||||
label="Groups"
|
||||
value={groups.length}
|
||||
color="purple"
|
||||
onClick={() => setPage("groups")}
|
||||
/>
|
||||
<div
|
||||
onClick={() => setPage("assignments")}
|
||||
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300"
|
||||
>
|
||||
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
||||
<span className="flex flex-col gap-1 items-center text-xl">
|
||||
<span className="text-lg">Assignments</span>
|
||||
<span className="font-semibold text-mti-purple-light">
|
||||
{assignments.filter((a) => !a.archived).length}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
const DefaultDashboard = () => (
|
||||
<>
|
||||
{corporateUserToShow && (
|
||||
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
|
||||
Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b>
|
||||
</div>
|
||||
)}
|
||||
<section
|
||||
className={clsx(
|
||||
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center",
|
||||
!!corporateUserToShow && "mt-12 xl:mt-6",
|
||||
)}>
|
||||
<IconCard
|
||||
onClick={() => setPage("students")}
|
||||
Icon={BsPersonFill}
|
||||
label="Students"
|
||||
value={users.filter(studentFilter).length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsClipboard2Data}
|
||||
label="Exams Performed"
|
||||
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard
|
||||
Icon={BsPaperclip}
|
||||
label="Average Level"
|
||||
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
||||
color="purple"
|
||||
/>
|
||||
<IconCard Icon={BsPeople} label="Groups" value={groups.length} color="purple" onClick={() => setPage("groups")} />
|
||||
<div
|
||||
onClick={() => setPage("assignments")}
|
||||
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
||||
<span className="flex flex-col gap-1 items-center text-xl">
|
||||
<span className="text-lg">Assignments</span>
|
||||
<span className="font-semibold text-mti-purple-light">{assignments.filter((a) => !a.archived).length}</span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Latest students</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(studentFilter)
|
||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Highest level students</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(studentFilter)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
calculateAverageLevel(b.levels) -
|
||||
calculateAverageLevel(a.levels)
|
||||
)
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Highest exam count students</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(studentFilter)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Object.keys(groupByExam(getStatsByStudent(b))).length -
|
||||
Object.keys(groupByExam(getStatsByStudent(a))).length
|
||||
)
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Latest students</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(studentFilter)
|
||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Highest level students</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(studentFilter)
|
||||
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||
<span className="p-4">Highest exam count students</span>
|
||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||
{users
|
||||
.filter(studentFilter)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
||||
)
|
||||
.map((x) => (
|
||||
<UserDisplay key={x.id} {...x} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
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 />}
|
||||
</>
|
||||
);
|
||||
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 {useRouter} from "next/router";
|
||||
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 {getLevelScore} from "@/utils/score";
|
||||
import {capitalize} from "lodash";
|
||||
import Modal from "@/components/Modal";
|
||||
import { UserSolution } from "@/interfaces/exam";
|
||||
import ai_usage from "@/utils/ai.detection";
|
||||
|
||||
interface Score {
|
||||
module: Module;
|
||||
@@ -25,13 +38,21 @@ interface Props {
|
||||
user: User;
|
||||
modules: Module[];
|
||||
scores: Score[];
|
||||
information: {
|
||||
timeSpent?: number;
|
||||
inactivity?: number;
|
||||
};
|
||||
solutions: UserSolution[];
|
||||
isLoading: boolean;
|
||||
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 [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);
|
||||
|
||||
@@ -62,7 +83,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
|
||||
const getTotalExercises = () => {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -86,6 +107,21 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
|
||||
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">
|
||||
<ModuleTitle
|
||||
module={selectedModule}
|
||||
@@ -94,7 +130,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
minTimer={exams.find((x) => x.module === selectedModule)!.minTimer}
|
||||
disableTimer
|
||||
/>
|
||||
<div className="flex gap-4 self-start">
|
||||
<div className="flex gap-4 self-start w-full">
|
||||
{modules.includes("reading") && (
|
||||
<div
|
||||
onClick={() => setSelectedModule("reading")}
|
||||
@@ -118,6 +154,7 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
</div>
|
||||
)}
|
||||
{modules.includes("writing") && (
|
||||
<div className="flex w-full justify-between items-center">
|
||||
<div
|
||||
onClick={() => setSelectedModule("writing")}
|
||||
className={clsx(
|
||||
@@ -127,6 +164,18 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
<BsPen className="h-6 w-6" />
|
||||
<span className="font-semibold">Writing</span>
|
||||
</div>
|
||||
{aiUsage >= 50 && user.type !== "student" && (
|
||||
<div className={clsx(
|
||||
"flex items-center justify-center border px-3 h-full rounded",
|
||||
{
|
||||
'bg-orange-100 border-orange-400 text-orange-700': aiUsage < 80,
|
||||
'bg-red-100 border-red-400 text-red-700': aiUsage >= 80,
|
||||
}
|
||||
)}>
|
||||
<span className="text-xs">AI Usage</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{modules.includes("speaking") && (
|
||||
<div
|
||||
@@ -247,6 +296,16 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
|
||||
</button>
|
||||
<span>Review {capitalize(selectedModule)}</span>
|
||||
</div>
|
||||
{(!!information.inactivity || !!information.timeSpent) && (
|
||||
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||
<button
|
||||
onClick={() => setIsExtraInformationOpen(true)}
|
||||
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||
<BsClipboardFill className="h-7 w-7 text-white" />
|
||||
</button>
|
||||
<span>Extra Information</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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 Button from "@/components/Low/Button";
|
||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import {renderSolution} from "@/components/Solutions";
|
||||
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 {defaultUserSolutions} from "@/utils/exams";
|
||||
import {countExercises} from "@/utils/moduleUtils";
|
||||
@@ -10,6 +12,7 @@ import {mdiArrowRight} from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import clsx from "clsx";
|
||||
import {Fragment, useEffect, useState} from "react";
|
||||
import {BsChevronDown, BsChevronUp} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
|
||||
interface Props {
|
||||
@@ -18,36 +21,84 @@ interface Props {
|
||||
onFinish: (userSolutions: UserSolution[]) => void;
|
||||
}
|
||||
|
||||
function TextComponent({part}: {part: LevelPart}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
||||
{!!part.context &&
|
||||
part.context
|
||||
.split(/\n|(\\n)/g)
|
||||
.filter((x) => x && x.length > 0 && x !== "\\n")
|
||||
.map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
<p key={index}>{line}</p>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [exerciseIndex, setExerciseIndex] = useState(0);
|
||||
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
|
||||
const [showBlankModal, setShowBlankModal] = useState(false);
|
||||
|
||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||
const {partIndex, setPartIndex} = useExamStore((state) => state);
|
||||
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
||||
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentQuestionIndex(0);
|
||||
}, [questionIndex]);
|
||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded && exerciseIndex === -1) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
}
|
||||
}, [hasExamEnded, exerciseIndex]);
|
||||
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
if (solution) {
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
|
||||
}
|
||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||
|
||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
||||
setExerciseIndex((prev) => prev + 1);
|
||||
const confirmFinishModule = (keepGoing?: boolean) => {
|
||||
if (!keepGoing) {
|
||||
setShowBlankModal(false);
|
||||
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);
|
||||
|
||||
@@ -59,41 +110,111 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
||||
};
|
||||
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
|
||||
}
|
||||
|
||||
if (exerciseIndex > 0) {
|
||||
setExerciseIndex((prev) => prev - 1);
|
||||
if (storeQuestionIndex > 0) {
|
||||
const exercise = getExercise();
|
||||
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: storeQuestionIndex}]);
|
||||
}
|
||||
setStoreQuestionIndex(0);
|
||||
|
||||
setExerciseIndex(exerciseIndex - 1);
|
||||
};
|
||||
|
||||
const getExercise = () => {
|
||||
const exercise = exam.exercises[exerciseIndex];
|
||||
const exercise = exam.parts[partIndex].exercises[exerciseIndex];
|
||||
return {
|
||||
...exercise,
|
||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||
};
|
||||
};
|
||||
|
||||
const calculateExerciseIndex = () => {
|
||||
if (partIndex === 0)
|
||||
return (
|
||||
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex + multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
|
||||
);
|
||||
|
||||
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
|
||||
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
|
||||
return (
|
||||
exercisesDone +
|
||||
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
|
||||
storeQuestionIndex +
|
||||
multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
|
||||
);
|
||||
};
|
||||
|
||||
const renderText = () => (
|
||||
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative py-8 px-16")}>
|
||||
<>
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<h4 className="text-xl font-semibold">
|
||||
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
||||
</h4>
|
||||
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
|
||||
</div>
|
||||
<TextComponent part={exam.parts[partIndex]} />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col h-full w-full gap-8 items-center">
|
||||
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
||||
<ModuleTitle
|
||||
minTimer={exam.minTimer}
|
||||
exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex}
|
||||
exerciseIndex={calculateExerciseIndex()}
|
||||
module="level"
|
||||
totalExercises={countExercises(exam.exercises)}
|
||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||
disableTimer={showSolutions}
|
||||
/>
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
<div
|
||||
className={clsx(
|
||||
"mb-20 w-full",
|
||||
partIndex > -1 && exerciseIndex > -1 && !!exam.parts[partIndex].context && "grid grid-cols-2 gap-4",
|
||||
)}>
|
||||
{partIndex > -1 && !!exam.parts[partIndex].context && renderText()}
|
||||
|
||||
{exerciseIndex > -1 &&
|
||||
partIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
|
||||
|
||||
{exerciseIndex > -1 &&
|
||||
partIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
</div>
|
||||
{exerciseIndex === -1 && partIndex > 0 && (
|
||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
||||
setPartIndex(partIndex - 1);
|
||||
}}
|
||||
className="max-w-[200px] w-full">
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{exerciseIndex === -1 && partIndex === 0 && (
|
||||
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
||||
Start now
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
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 [showBlankModal, setShowBlankModal] = useState(false);
|
||||
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
|
||||
@@ -64,10 +62,6 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
}
|
||||
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentQuestionIndex(0);
|
||||
}, [questionIndex]);
|
||||
|
||||
const confirmFinishModule = (keepGoing?: boolean) => {
|
||||
if (!keepGoing) {
|
||||
setShowBlankModal(false);
|
||||
@@ -220,14 +214,14 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
||||
partIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
|
||||
|
||||
{/* Solution renderer */}
|
||||
{exerciseIndex > -1 &&
|
||||
partIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
</div>
|
||||
|
||||
{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" />
|
||||
{part.text.content
|
||||
.split(/\n|(\\n)/g)
|
||||
.filter((x) => x && x.length > 0)
|
||||
.filter((x) => x && x.length > 0 && x !== "\\n")
|
||||
.map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{exerciseType === "matchSentences" && (
|
||||
@@ -106,8 +106,6 @@ function TextComponent({part, exerciseType}: {part: ReadingPart; exerciseType: s
|
||||
}
|
||||
|
||||
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 [showBlankModal, setShowBlankModal] = useState(false);
|
||||
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(() => {
|
||||
if (hasExamEnded && exerciseIndex === -1) {
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
@@ -314,13 +308,13 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
|
||||
partIndex > -1 &&
|
||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
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, setCurrentQuestionIndex)}
|
||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
</div>
|
||||
{exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
|
||||
<Button
|
||||
|
||||
@@ -2,7 +2,7 @@ import {renderExercise} from "@/components/Exercises";
|
||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import {renderSolution} from "@/components/Solutions";
|
||||
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 {defaultUserSolutions} from "@/utils/exams";
|
||||
import {countExercises} from "@/utils/moduleUtils";
|
||||
@@ -20,25 +20,26 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function Speaking({exam, showSolutions = false, onFinish}: Props) {
|
||||
const [questionIndex, setQuestionIndex] = useState(0);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [speakingPromptsDone, setSpeakingPromptsDone] = useState<{id: string; amount: number}[]>([]);
|
||||
|
||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
||||
|
||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentQuestionIndex(0);
|
||||
}, [questionIndex]);
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
if (solution) {
|
||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]);
|
||||
}
|
||||
setQuestionIndex((prev) => prev + currentQuestionIndex);
|
||||
|
||||
if (questionIndex > 0) {
|
||||
const exercise = getExercise();
|
||||
setSpeakingPromptsDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: questionIndex}]);
|
||||
}
|
||||
setQuestionIndex(0);
|
||||
|
||||
if (exerciseIndex + 1 < exam.exercises.length) {
|
||||
setExerciseIndex(exerciseIndex + 1);
|
||||
@@ -71,8 +72,9 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
||||
const exercise = exam.exercises[exerciseIndex];
|
||||
return {
|
||||
...exercise,
|
||||
variant: exerciseIndex < 2 && exercise.type === "interactiveSpeaking" ? "initial" : undefined,
|
||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
||||
};
|
||||
} as SpeakingExercise | InteractiveSpeakingExercise;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -81,7 +83,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
||||
<ModuleTitle
|
||||
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
|
||||
minTimer={exam.minTimer}
|
||||
exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex}
|
||||
exerciseIndex={exerciseIndex + 1 + questionIndex + speakingPromptsDone.reduce((acc, curr) => acc + curr.amount, 0)}
|
||||
module="speaking"
|
||||
totalExercises={countExercises(exam.exercises)}
|
||||
disableTimer={showSolutions}
|
||||
@@ -89,11 +91,11 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise, setCurrentQuestionIndex)}
|
||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {initializeApp} from "firebase/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 = {
|
||||
apiKey: process.env.FIREBASE_PUBLIC_API_KEY || "",
|
||||
@@ -16,8 +17,8 @@ const firebaseConfig = {
|
||||
export const app = initializeApp(firebaseConfig, Math.random().toString());
|
||||
export const adminApp = admin.initializeApp(
|
||||
{
|
||||
credential: admin.cert(serviceAccount),
|
||||
credential: admin.cert(process.env.ENVIRONMENT === "platform" ? platformServiceAccount : stagingServiceAccount),
|
||||
},
|
||||
Math.random().toString(),
|
||||
);
|
||||
export const storage = getStorage(app);
|
||||
export const storage = getStorage(app);
|
||||
|
||||
@@ -2,16 +2,23 @@ import {Group, User} from "@/interfaces/user";
|
||||
import axios from "axios";
|
||||
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 [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const isMasterType = userType?.startsWith('master');
|
||||
|
||||
const getData = () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const url = admin ? `/api/groups?admin=${admin}` : "/api/groups";
|
||||
axios
|
||||
.get<Group[]>("/api/groups")
|
||||
.get<Group[]>(url)
|
||||
.then((response) => {
|
||||
if(isMasterType) {
|
||||
return setGroups(response.data);
|
||||
}
|
||||
const filter = (g: Group) => g.admin === admin || g.participants.includes(admin || "");
|
||||
|
||||
const filteredGroups = admin ? response.data.filter(filter) : response.data;
|
||||
@@ -20,7 +27,7 @@ export default function useGroups(admin?: string) {
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(getData, [admin]);
|
||||
useEffect(getData, [admin, isMasterType]);
|
||||
|
||||
return {groups, isLoading, isError, reload: getData};
|
||||
}
|
||||
|
||||
@@ -27,13 +27,18 @@ export interface ReadingPart {
|
||||
export interface LevelExam {
|
||||
module: "level";
|
||||
id: string;
|
||||
exercises: Exercise[];
|
||||
parts: LevelPart[];
|
||||
minTimer: number;
|
||||
isDiagnostic: boolean;
|
||||
variant?: Variant;
|
||||
difficulty?: Difficulty;
|
||||
}
|
||||
|
||||
export interface LevelPart {
|
||||
context?: string;
|
||||
exercises: Exercise[];
|
||||
}
|
||||
|
||||
export interface ListeningExam {
|
||||
parts: ListeningPart[];
|
||||
id: string;
|
||||
@@ -106,18 +111,18 @@ export type Exercise =
|
||||
export interface Evaluation {
|
||||
comment: string;
|
||||
overall: number;
|
||||
task_response: {[key: string]: number};
|
||||
task_response: {[key: string]: number | {grade: number; comment: string}};
|
||||
misspelled_pairs?: {correction: string | null; misspelled: string}[];
|
||||
}
|
||||
|
||||
interface InteractiveSpeakingEvaluation extends Evaluation {
|
||||
perfect_answer_1?: string;
|
||||
perfect_answer_1?: {answer: string};
|
||||
transcript_1?: string;
|
||||
fixed_text_1?: string;
|
||||
perfect_answer_2?: string;
|
||||
perfect_answer_2?: {answer: string};
|
||||
transcript_2?: string;
|
||||
fixed_text_2?: string;
|
||||
perfect_answer_3?: string;
|
||||
perfect_answer_3?: {answer: string};
|
||||
transcript_3?: string;
|
||||
fixed_text_3?: string;
|
||||
}
|
||||
@@ -147,17 +152,36 @@ export interface WritingExercise {
|
||||
userSolutions: {
|
||||
id: string;
|
||||
solution: string;
|
||||
evaluation?: CommonEvaluation;
|
||||
evaluation?: WritingEvaluation;
|
||||
}[];
|
||||
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 {
|
||||
id: string;
|
||||
type: "speaking";
|
||||
title: string;
|
||||
text: string;
|
||||
prompts: string[];
|
||||
suffix?: string;
|
||||
video_url: string;
|
||||
userSolutions: {
|
||||
id: string;
|
||||
@@ -171,6 +195,8 @@ export interface InteractiveSpeakingExercise {
|
||||
id: string;
|
||||
type: "interactiveSpeaking";
|
||||
title: string;
|
||||
first_title?: string;
|
||||
second_title?: string;
|
||||
text: string;
|
||||
prompts: {text: string; video_url: string}[];
|
||||
userSolutions: {
|
||||
@@ -179,13 +205,16 @@ export interface InteractiveSpeakingExercise {
|
||||
evaluation?: InteractiveSpeakingEvaluation;
|
||||
}[];
|
||||
topic?: string;
|
||||
first_topic?: string;
|
||||
second_topic?: string;
|
||||
variant?: "initial" | "final";
|
||||
}
|
||||
|
||||
export interface FillBlanksExercise {
|
||||
prompt: string; // *EXAMPLE: "Complete the summary below. Click a blank to select the corresponding word for it."
|
||||
type: "fillBlanks";
|
||||
id: string;
|
||||
words: string[]; // *EXAMPLE: ["preserve", "unaware"]
|
||||
words: (string | {letter: string; word: string})[]; // *EXAMPLE: ["preserve", "unaware"]
|
||||
text: string; // *EXAMPLE: "They tried to {{1}} burning"
|
||||
allowRepetition: boolean;
|
||||
solutions: {
|
||||
|
||||
@@ -1,55 +1,56 @@
|
||||
export interface TokenSuccess {
|
||||
scope: string;
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
app_id: string;
|
||||
expires_in: number;
|
||||
nonce: string;
|
||||
scope: string;
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
app_id: string;
|
||||
expires_in: number;
|
||||
nonce: string;
|
||||
}
|
||||
|
||||
export interface TokenError {
|
||||
error: string;
|
||||
error_description: string;
|
||||
error: string;
|
||||
error_description: string;
|
||||
}
|
||||
|
||||
export interface Package {
|
||||
id: string;
|
||||
currency: string;
|
||||
duration: number;
|
||||
duration_unit: DurationUnit;
|
||||
price: number;
|
||||
id: string;
|
||||
currency: string;
|
||||
duration: number;
|
||||
duration_unit: DurationUnit;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export interface Discount {
|
||||
id: string;
|
||||
percentage: number;
|
||||
domain: string;
|
||||
id: string;
|
||||
percentage: number;
|
||||
domain: string;
|
||||
validUntil?: Date;
|
||||
}
|
||||
|
||||
export type DurationUnit = "weeks" | "days" | "months" | "years";
|
||||
|
||||
export interface Payment {
|
||||
id: string;
|
||||
corporate: string;
|
||||
agent?: string;
|
||||
agentCommission: number;
|
||||
agentValue: number;
|
||||
currency: string;
|
||||
value: number;
|
||||
isPaid: boolean;
|
||||
date: Date | string;
|
||||
corporateTransfer?: string;
|
||||
commissionTransfer?: string;
|
||||
id: string;
|
||||
corporate: string;
|
||||
agent?: string;
|
||||
agentCommission: number;
|
||||
agentValue: number;
|
||||
currency: string;
|
||||
value: number;
|
||||
isPaid: boolean;
|
||||
date: Date | string;
|
||||
corporateTransfer?: string;
|
||||
commissionTransfer?: string;
|
||||
}
|
||||
|
||||
export interface PaypalPayment {
|
||||
orderId: string;
|
||||
userId: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
value: number;
|
||||
currency: string;
|
||||
subscriptionDuration: number;
|
||||
subscriptionDurationUnit: DurationUnit;
|
||||
subscriptionExpirationDate: Date;
|
||||
orderId: string;
|
||||
userId: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
value: number;
|
||||
currency: string;
|
||||
subscriptionDuration: number;
|
||||
subscriptionDurationUnit: DurationUnit;
|
||||
subscriptionExpirationDate: Date;
|
||||
}
|
||||
|
||||
49
src/interfaces/permissions.ts
Normal file
49
src/interfaces/permissions.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export const markets = ["au", "br", "de"] as const;
|
||||
|
||||
export const permissions = [
|
||||
// generate codes are basicly invites
|
||||
"createCodeStudent",
|
||||
"createCodeTeacher",
|
||||
"createCodeCorporate",
|
||||
"createCodeCountryManager",
|
||||
"createCodeAdmin",
|
||||
// exams
|
||||
"createReadingExam",
|
||||
"createListeningExam",
|
||||
"createWritingExam",
|
||||
"createSpeakingExam",
|
||||
"createLevelExam",
|
||||
// view pages
|
||||
"viewExams",
|
||||
"viewExercises",
|
||||
"viewRecords",
|
||||
"viewStats",
|
||||
"viewTickets",
|
||||
"viewPaymentRecords",
|
||||
// view data
|
||||
"viewStudent",
|
||||
"viewTeacher",
|
||||
"viewCorporate",
|
||||
"viewCountryManager",
|
||||
"viewAdmin",
|
||||
// edit data
|
||||
"editStudent",
|
||||
"editTeacher",
|
||||
"editCorporate",
|
||||
"editCountryManager",
|
||||
"editAdmin",
|
||||
// delete data
|
||||
"deleteStudent",
|
||||
"deleteTeacher",
|
||||
"deleteCorporate",
|
||||
"deleteCountryManager",
|
||||
"deleteAdmin",
|
||||
] as const;
|
||||
|
||||
export type PermissionType = (typeof permissions)[keyof typeof permissions];
|
||||
|
||||
export interface Permission {
|
||||
id: string;
|
||||
type: PermissionType;
|
||||
users: string[];
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from ".";
|
||||
import { InstructorGender } from "./exam";
|
||||
import { PermissionType } from "./permissions";
|
||||
|
||||
export type User =
|
||||
| StudentUser
|
||||
@@ -7,7 +8,8 @@ export type User =
|
||||
| CorporateUser
|
||||
| AgentUser
|
||||
| AdminUser
|
||||
| DeveloperUser;
|
||||
| DeveloperUser
|
||||
| MasterCorporateUser;
|
||||
export type UserStatus = "active" | "disabled" | "paymentDue";
|
||||
|
||||
export interface BasicUser {
|
||||
@@ -25,6 +27,7 @@ export interface BasicUser {
|
||||
subscriptionExpirationDate?: null | Date;
|
||||
registrationDate?: Date;
|
||||
status: UserStatus;
|
||||
permissions: PermissionType[],
|
||||
}
|
||||
|
||||
export interface StudentUser extends BasicUser {
|
||||
@@ -45,6 +48,12 @@ export interface CorporateUser extends BasicUser {
|
||||
demographicInformation?: DemographicCorporateInformation;
|
||||
}
|
||||
|
||||
export interface MasterCorporateUser extends BasicUser {
|
||||
type: "mastercorporate";
|
||||
corporateInformation: CorporateInformation;
|
||||
demographicInformation?: DemographicCorporateInformation;
|
||||
}
|
||||
|
||||
export interface AgentUser extends BasicUser {
|
||||
type: "agent";
|
||||
agentInformation: AgentInformation;
|
||||
@@ -131,6 +140,7 @@ export interface Stat {
|
||||
solutions: any[];
|
||||
type: string;
|
||||
timeSpent?: number;
|
||||
inactivity?: number;
|
||||
assignment?: string;
|
||||
score: {
|
||||
correct: number;
|
||||
@@ -166,7 +176,8 @@ export type Type =
|
||||
| "corporate"
|
||||
| "admin"
|
||||
| "developer"
|
||||
| "agent";
|
||||
| "agent"
|
||||
| "mastercorporate";
|
||||
export const userTypes: Type[] = [
|
||||
"student",
|
||||
"teacher",
|
||||
@@ -174,4 +185,5 @@ export const userTypes: Type[] = [
|
||||
"admin",
|
||||
"developer",
|
||||
"agent",
|
||||
"mastercorporate",
|
||||
];
|
||||
|
||||
@@ -1,250 +1,366 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {Type, User} from "@/interfaces/user";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import { Type, User } from "@/interfaces/user";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize, uniqBy} from "lodash";
|
||||
import { capitalize, uniqBy } from "lodash";
|
||||
import moment from "moment";
|
||||
import {useEffect, useState} from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ReactDatePicker from "react-datepicker";
|
||||
import {toast} from "react-toastify";
|
||||
import { toast } from "react-toastify";
|
||||
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 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]: Type[]} = {
|
||||
student: [],
|
||||
teacher: [],
|
||||
agent: [],
|
||||
corporate: ["student", "teacher"],
|
||||
admin: ["student", "teacher", "agent", "corporate", "admin"],
|
||||
developer: ["student", "teacher", "agent", "corporate", "admin", "developer"],
|
||||
const USER_TYPE_PERMISSIONS: {
|
||||
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
||||
} = {
|
||||
student: {
|
||||
perm: "createCodeStudent",
|
||||
list: [],
|
||||
},
|
||||
teacher: {
|
||||
perm: "createCodeTeacher",
|
||||
list: [],
|
||||
},
|
||||
agent: {
|
||||
perm: "createCodeCountryManager",
|
||||
list: [],
|
||||
},
|
||||
corporate: {
|
||||
perm: "createCodeCorporate",
|
||||
list: ["student", "teacher"],
|
||||
},
|
||||
mastercorporate: {
|
||||
perm: undefined,
|
||||
list: ["student", "teacher", "corporate"],
|
||||
},
|
||||
admin: {
|
||||
perm: "createCodeAdmin",
|
||||
list: [
|
||||
"student",
|
||||
"teacher",
|
||||
"agent",
|
||||
"corporate",
|
||||
"admin",
|
||||
"mastercorporate",
|
||||
],
|
||||
},
|
||||
developer: {
|
||||
perm: undefined,
|
||||
list: [
|
||||
"student",
|
||||
"teacher",
|
||||
"agent",
|
||||
"corporate",
|
||||
"admin",
|
||||
"developer",
|
||||
"mastercorporate",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default function BatchCodeGenerator({user}: {user: User}) {
|
||||
const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
|
||||
);
|
||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||
const [type, setType] = useState<Type>("student");
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
export default function BatchCodeGenerator({ user }: { user: User }) {
|
||||
const [infos, setInfos] = useState<
|
||||
{ email: string; name: string; passport_id: string }[]
|
||||
>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||
user?.subscriptionExpirationDate
|
||||
? moment(user.subscriptionExpirationDate).toDate()
|
||||
: 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({
|
||||
accept: ".xlsx",
|
||||
multiple: false,
|
||||
readAs: "ArrayBuffer",
|
||||
});
|
||||
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||
accept: ".xlsx",
|
||||
multiple: false,
|
||||
readAs: "ArrayBuffer",
|
||||
});
|
||||
|
||||
useEffect(() => console.log(expiryDate), [expiryDate]);
|
||||
useEffect(() => {
|
||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||
}, [isExpiryDateEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||
}, [isExpiryDateEnabled]);
|
||||
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
|
||||
] = 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 (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] = 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) {
|
||||
toast.error(
|
||||
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!"
|
||||
);
|
||||
return clear();
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
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 generateAndInvite = async () => {
|
||||
const newUsers = infos.filter(
|
||||
(x) => !users.map((u) => u.email).includes(x.email)
|
||||
);
|
||||
const existingUsers = infos
|
||||
.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[];
|
||||
|
||||
const generateAndInvite = async () => {
|
||||
const newUsers = infos.filter((x) => !users.map((u) => u.email).includes(x.email));
|
||||
const existingUsers = infos
|
||||
.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[];
|
||||
const newUsersSentence =
|
||||
newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
|
||||
const existingUsersSentence =
|
||||
existingUsers.length > 0
|
||||
? `invite ${existingUsers.length} registered student(s)`
|
||||
: 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;
|
||||
const existingUsersSentence = existingUsers.length > 0 ? `invite ${existingUsers.length} registered student(s)` : undefined;
|
||||
if (
|
||||
!confirm(
|
||||
`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
setIsLoading(true);
|
||||
Promise.all(
|
||||
existingUsers.map(
|
||||
async (u) =>
|
||||
await axios.post(`/api/invites`, { to: u.id, from: user.id })
|
||||
)
|
||||
)
|
||||
.then(() =>
|
||||
toast.success(
|
||||
`Successfully invited ${existingUsers.length} registered student(s)!`
|
||||
)
|
||||
)
|
||||
.finally(() => {
|
||||
if (newUsers.length === 0) setIsLoading(false);
|
||||
});
|
||||
|
||||
setIsLoading(true);
|
||||
Promise.all(existingUsers.map(async (u) => await axios.post(`/api/invites`, {to: u.id, from: user.id})))
|
||||
.then(() => toast.success(`Successfully invited ${existingUsers.length} registered student(s)!`))
|
||||
.finally(() => {
|
||||
if (newUsers.length === 0) setIsLoading(false);
|
||||
});
|
||||
if (newUsers.length > 0) generateCode(type, newUsers);
|
||||
setInfos([]);
|
||||
};
|
||||
|
||||
if (newUsers.length > 0) generateCode(type, newUsers);
|
||||
setInfos([]);
|
||||
};
|
||||
const generateCode = (type: Type, informations: typeof infos) => {
|
||||
const uid = new ShortUniqueId();
|
||||
const codes = informations.map(() => uid.randomUUID(6));
|
||||
|
||||
const generateCode = (type: Type, informations: typeof infos) => {
|
||||
const uid = new ShortUniqueId();
|
||||
const codes = informations.map(() => uid.randomUUID(6));
|
||||
setIsLoading(true);
|
||||
axios
|
||||
.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);
|
||||
axios
|
||||
.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;
|
||||
}
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, { toastId: "forbidden" });
|
||||
}
|
||||
})
|
||||
.catch(({ response: { status, data } }) => {
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, { toastId: "forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, {toastId: "forbidden"});
|
||||
}
|
||||
})
|
||||
.catch(({response: {status, data}}) => {
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, {toastId: "forbidden"});
|
||||
return;
|
||||
}
|
||||
toast.error(`Something went wrong, please try again later!`, {
|
||||
toastId: "error",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
return clear();
|
||||
});
|
||||
};
|
||||
|
||||
toast.error(`Something went wrong, please try again later!`, {
|
||||
toastId: "error",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
return clear();
|
||||
});
|
||||
};
|
||||
|
||||
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>
|
||||
</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>
|
||||
{user && (user.type === "developer" || user.type === "admin" || user.type === "corporate") && (
|
||||
<>
|
||||
<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 isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
{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",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}
|
||||
filterDate={(date) =>
|
||||
moment(date).isAfter(new Date()) &&
|
||||
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
|
||||
}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<label className="text-mti-gray-dim text-base font-normal">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) => USER_TYPE_PERMISSIONS[user.type].includes(x as Type))
|
||||
.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>
|
||||
</>
|
||||
);
|
||||
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>
|
||||
</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>
|
||||
{user &&
|
||||
checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
||||
<>
|
||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
Expiry Date
|
||||
</label>
|
||||
<Checkbox
|
||||
isChecked={isExpiryDateEnabled}
|
||||
onChange={setIsExpiryDateEnabled}
|
||||
disabled={!!user.subscriptionExpirationDate}
|
||||
>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
{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",
|
||||
"transition duration-300 ease-in-out"
|
||||
)}
|
||||
filterDate={(date) =>
|
||||
moment(date).isAfter(new Date()) &&
|
||||
(user.subscriptionExpirationDate
|
||||
? moment(date).isBefore(user.subscriptionExpirationDate)
|
||||
: true)
|
||||
}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<label className="text-mti-gray-dim text-base font-normal">
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,125 +1,197 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||
import {Type, User} from "@/interfaces/user";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||
import { Type, User } from "@/interfaces/user";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize} from "lodash";
|
||||
import { capitalize } from "lodash";
|
||||
import moment from "moment";
|
||||
import {useEffect, useState} from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ReactDatePicker from "react-datepicker";
|
||||
import {toast} from "react-toastify";
|
||||
import { toast } from "react-toastify";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
import { checkAccess } from "@/utils/permissions";
|
||||
import { PermissionType } from "@/interfaces/permissions";
|
||||
|
||||
const USER_TYPE_PERMISSIONS: {[key in Type]: Type[]} = {
|
||||
student: [],
|
||||
teacher: [],
|
||||
agent: [],
|
||||
corporate: ["student", "teacher"],
|
||||
admin: ["student", "teacher", "agent", "corporate", "admin"],
|
||||
developer: ["student", "teacher", "agent", "corporate", "admin", "developer"],
|
||||
const USER_TYPE_PERMISSIONS: {
|
||||
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
||||
} = {
|
||||
student: {
|
||||
perm: "createCodeStudent",
|
||||
list: [],
|
||||
},
|
||||
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}) {
|
||||
const [generatedCode, setGeneratedCode] = useState<string>();
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
|
||||
);
|
||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||
const [type, setType] = useState<Type>("student");
|
||||
export default function CodeGenerator({ user }: { user: User }) {
|
||||
const [generatedCode, setGeneratedCode] = useState<string>();
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||
user?.subscriptionExpirationDate
|
||||
? moment(user.subscriptionExpirationDate).toDate()
|
||||
: null
|
||||
);
|
||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||
const [type, setType] = useState<Type>("student");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||
}, [isExpiryDateEnabled]);
|
||||
useEffect(() => {
|
||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||
}, [isExpiryDateEnabled]);
|
||||
|
||||
const generateCode = (type: Type) => {
|
||||
const uid = new ShortUniqueId();
|
||||
const code = uid.randomUUID(6);
|
||||
const generateCode = (type: Type) => {
|
||||
const uid = new ShortUniqueId();
|
||||
const code = uid.randomUUID(6);
|
||||
|
||||
axios
|
||||
.post("/api/code", {type, codes: [code], expiryDate})
|
||||
.then(({data, status}) => {
|
||||
if (data.ok) {
|
||||
toast.success(`Successfully generated a ${capitalize(type)} code!`, {toastId: "success"});
|
||||
setGeneratedCode(code);
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.post("/api/code", { type, codes: [code], expiryDate })
|
||||
.then(({ data, status }) => {
|
||||
if (data.ok) {
|
||||
toast.success(`Successfully generated a ${capitalize(type)} code!`, {
|
||||
toastId: "success",
|
||||
});
|
||||
setGeneratedCode(code);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, {toastId: "forbidden"});
|
||||
}
|
||||
})
|
||||
.catch(({response: {status, data}}) => {
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, {toastId: "forbidden"});
|
||||
return;
|
||||
}
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, { toastId: "forbidden" });
|
||||
}
|
||||
})
|
||||
.catch(({ response: { status, data } }) => {
|
||||
if (status === 403) {
|
||||
toast.error(data.reason, { toastId: "forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(`Something went wrong, please try again later!`, {toastId: "error"});
|
||||
});
|
||||
};
|
||||
toast.error(`Something went wrong, please try again later!`, {
|
||||
toastId: "error",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||
<label className="font-normal text-base text-mti-gray-dim">User Code Generator</label>
|
||||
{user && (
|
||||
<select
|
||||
defaultValue="student"
|
||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
||||
{Object.keys(USER_TYPE_LABELS)
|
||||
.filter((x) => USER_TYPE_PERMISSIONS[user.type].includes(x as Type))
|
||||
.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{user && (user.type === "developer" || user.type === "admin" || user.type === "corporate") && (
|
||||
<>
|
||||
<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 isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
{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",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}
|
||||
filterDate={(date) =>
|
||||
moment(date).isAfter(new Date()) &&
|
||||
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
|
||||
}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button onClick={() => generateCode(type)} 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>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
User Code Generator
|
||||
</label>
|
||||
{user && (
|
||||
<select
|
||||
defaultValue="student"
|
||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"
|
||||
>
|
||||
{Object.keys(USER_TYPE_LABELS)
|
||||
.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>
|
||||
)}
|
||||
{user &&
|
||||
checkAccess(user, ["developer", "admin", "corporate"]) && (
|
||||
<>
|
||||
<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
|
||||
isChecked={isExpiryDateEnabled}
|
||||
onChange={setIsExpiryDateEnabled}
|
||||
disabled={!!user.subscriptionExpirationDate}
|
||||
>
|
||||
Enabled
|
||||
</Checkbox>
|
||||
</div>
|
||||
{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",
|
||||
"transition duration-300 ease-in-out"
|
||||
)}
|
||||
filterDate={(date) =>
|
||||
moment(date).isAfter(new Date()) &&
|
||||
(user.subscriptionExpirationDate
|
||||
? moment(date).isBefore(user.subscriptionExpirationDate)
|
||||
: true)
|
||||
}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={expiryDate}
|
||||
onChange={(date) => setExpiryDate(date)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => generateCode(type)}
|
||||
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";
|
||||
import axios from "axios";
|
||||
import moment from "moment";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { BsTrash } from "react-icons/bs";
|
||||
import { toast } from "react-toastify";
|
||||
import ReactDatePicker from "react-datepicker";
|
||||
import clsx from "clsx";
|
||||
|
||||
const columnHelper = createColumnHelper<Code>();
|
||||
|
||||
@@ -41,41 +43,51 @@ export default function CodeList({ user }: { user: User }) {
|
||||
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
|
||||
|
||||
const [filteredCorporate, setFilteredCorporate] = useState<User | undefined>(
|
||||
user?.type === "corporate" ? user : undefined,
|
||||
user?.type === "corporate" ? user : undefined
|
||||
);
|
||||
const [filterAvailability, setFilterAvailability] = useState<
|
||||
"in-use" | "unused"
|
||||
>();
|
||||
|
||||
const [filteredCodes, setFilteredCodes] = useState<Code[]>([]);
|
||||
// const [filteredCodes, setFilteredCodes] = useState<Code[]>([]);
|
||||
|
||||
const { users } = useUsers();
|
||||
const { codes, reload } = useCodes(
|
||||
user?.type === "corporate" ? user?.id : undefined,
|
||||
user?.type === "corporate" ? user?.id : undefined
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let result = [...codes];
|
||||
if (filteredCorporate)
|
||||
result = result.filter((x) => x.creator === filteredCorporate.id);
|
||||
if (filterAvailability)
|
||||
result = result.filter((x) =>
|
||||
filterAvailability === "in-use" ? !!x.userId : !x.userId,
|
||||
);
|
||||
const [startDate, setStartDate] = useState<Date | null>(moment("01/01/2023").toDate());
|
||||
const [endDate, setEndDate] = useState<Date | null>(moment().endOf("day").toDate());
|
||||
const filteredCodes = useMemo(() => {
|
||||
return codes.filter((x) => {
|
||||
// TODO: if the expiry date is missing, it does not make sense to filter by date
|
||||
// so we need to find a way to handle this edge case
|
||||
if(startDate && endDate && x.expiryDate) {
|
||||
const date = moment(x.expiryDate);
|
||||
if(date.isBefore(startDate) || date.isAfter(endDate)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (filteredCorporate && x.creator !== filteredCorporate.id) return false;
|
||||
if (filterAvailability) {
|
||||
if (filterAvailability === "in-use" && !x.userId) return false;
|
||||
if (filterAvailability === "unused" && x.userId) return false;
|
||||
}
|
||||
|
||||
setFilteredCodes(result);
|
||||
}, [codes, filteredCorporate, filterAvailability]);
|
||||
return true;
|
||||
});
|
||||
}, [codes, startDate, endDate, filteredCorporate, filterAvailability]);
|
||||
|
||||
const toggleCode = (id: string) => {
|
||||
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) => {
|
||||
if (checked)
|
||||
return setSelectedCodes(
|
||||
filteredCodes.filter((x) => !x.userId).map((x) => x.code),
|
||||
filteredCodes.filter((x) => !x.userId).map((x) => x.code)
|
||||
);
|
||||
|
||||
return setSelectedCodes([]);
|
||||
@@ -137,7 +149,7 @@ export default function CodeList({ user }: { user: User }) {
|
||||
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("code", {
|
||||
id: "code",
|
||||
id: "codeCheckbox",
|
||||
header: () => (
|
||||
<Checkbox
|
||||
disabled={filteredCodes.filter((x) => !x.userId).length === 0}
|
||||
@@ -242,18 +254,20 @@ export default function CodeList({ user }: { user: User }) {
|
||||
}
|
||||
options={users
|
||||
.filter((x) =>
|
||||
["admin", "developer", "corporate"].includes(x.type),
|
||||
["admin", "developer", "corporate"].includes(x.type)
|
||||
)
|
||||
.map((x) => ({
|
||||
label: `${x.type === "corporate" ? x.corporateInformation?.companyInformation?.name || x.name : x.name} (${
|
||||
USER_TYPE_LABELS[x.type]
|
||||
})`,
|
||||
label: `${
|
||||
x.type === "corporate"
|
||||
? x.corporateInformation?.companyInformation?.name || x.name
|
||||
: x.name
|
||||
} (${USER_TYPE_LABELS[x.type]})`,
|
||||
value: x.id,
|
||||
user: x,
|
||||
}))}
|
||||
onChange={(value) =>
|
||||
setFilteredCorporate(
|
||||
value ? users.find((x) => x.id === value?.value) : undefined,
|
||||
value ? users.find((x) => x.id === value?.value) : undefined
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -267,10 +281,32 @@ export default function CodeList({ user }: { user: User }) {
|
||||
]}
|
||||
onChange={(value) =>
|
||||
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 className="flex gap-4 items-center">
|
||||
<span>{selectedCodes.length} code(s) selected</span>
|
||||
@@ -295,7 +331,7 @@ export default function CodeList({ user }: { user: User }) {
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
header.getContext()
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
|
||||
@@ -7,336 +7,301 @@ import useCodes from "@/hooks/useCodes";
|
||||
import useDiscounts from "@/hooks/useDiscounts";
|
||||
import useUser from "@/hooks/useUser";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import { Discount } from "@/interfaces/paypal";
|
||||
import { Code, User } from "@/interfaces/user";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import {Discount} from "@/interfaces/paypal";
|
||||
import {Code, User} from "@/interfaces/user";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import moment from "moment";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BsPencil, BsTrash } from "react-icons/bs";
|
||||
import { toast } from "react-toastify";
|
||||
import {useEffect, useState} from "react";
|
||||
import ReactDatePicker from "react-datepicker";
|
||||
import {BsPencil, BsTrash} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
|
||||
const columnHelper = createColumnHelper<Discount>();
|
||||
|
||||
const DiscountCreator = ({
|
||||
discount,
|
||||
onClose,
|
||||
}: {
|
||||
discount?: Discount;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const [percentage, setPercentage] = useState(discount?.percentage);
|
||||
const [domain, setDomain] = useState(discount?.domain);
|
||||
const DiscountCreator = ({discount, onClose}: {discount?: Discount; onClose: () => void}) => {
|
||||
const [percentage, setPercentage] = useState(discount?.percentage);
|
||||
const [domain, setDomain] = useState(discount?.domain);
|
||||
const [validUntil, setValidUntil] = useState(discount?.validUntil);
|
||||
|
||||
const submit = async () => {
|
||||
const body = { percentage, domain };
|
||||
const submit = async () => {
|
||||
const body = {percentage, domain, validUntil: validUntil?.toISOString() || undefined};
|
||||
|
||||
if (discount) {
|
||||
return axios
|
||||
.patch(`/api/discounts/${discount.id}`, body)
|
||||
.then(() => {
|
||||
toast.success("Discount has been edited successfully!");
|
||||
onClose();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
});
|
||||
}
|
||||
if (discount) {
|
||||
return axios
|
||||
.patch(`/api/discounts/${discount.id}`, body)
|
||||
.then(() => {
|
||||
toast.success("Discount has been edited successfully!");
|
||||
onClose();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
});
|
||||
}
|
||||
|
||||
return axios
|
||||
.post(`/api/discounts`, body)
|
||||
.then(() => {
|
||||
toast.success("New discount has been created successfully!");
|
||||
onClose();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
});
|
||||
};
|
||||
return axios
|
||||
.post(`/api/discounts`, body)
|
||||
.then(() => {
|
||||
toast.success("New discount has been created successfully!");
|
||||
onClose();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 py-8">
|
||||
<div className="w-full grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Domain *
|
||||
</label>
|
||||
<div className="flex gap-4 items-center">
|
||||
<Input
|
||||
defaultValue={domain}
|
||||
placeholder="encoach.com"
|
||||
name="domain"
|
||||
type="text"
|
||||
onChange={(e) => setDomain(e.replaceAll("@", ""))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Percentage (in %) *
|
||||
</label>
|
||||
<div className="flex gap-4 items-center">
|
||||
<Input
|
||||
defaultValue={percentage}
|
||||
placeholder="20"
|
||||
name="percentage"
|
||||
type="number"
|
||||
onChange={(e) => setPercentage(parseFloat(e))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full justify-end items-center gap-8 mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
className="w-full max-w-[200px]"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full max-w-[200px]"
|
||||
onClick={submit}
|
||||
disabled={!percentage || !domain}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-8 py-8">
|
||||
<div className="w-full grid grid-cols-1 gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Domain *</label>
|
||||
<div className="flex gap-4 items-center">
|
||||
<Input
|
||||
defaultValue={domain}
|
||||
placeholder="encoach.com"
|
||||
name="domain"
|
||||
type="text"
|
||||
onChange={(e) => setDomain(e.replaceAll("@", ""))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Percentage (in %) *</label>
|
||||
<div className="flex gap-4 items-center">
|
||||
<Input
|
||||
defaultValue={percentage}
|
||||
placeholder="20"
|
||||
name="percentage"
|
||||
type="number"
|
||||
onChange={(e) => setPercentage(parseFloat(e))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Valid Until</label>
|
||||
<div className="flex gap-4 items-center w-full">
|
||||
<ReactDatePicker
|
||||
wrapperClassName="w-full z-[900]"
|
||||
calendarClassName="z-[900]"
|
||||
popperClassName="z-[900]"
|
||||
isClearable
|
||||
className={clsx(
|
||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||
"hover:border-mti-purple tooltip",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}
|
||||
filterDate={(date) => moment(date).isAfter(new Date())}
|
||||
dateFormat="dd/MM/yyyy"
|
||||
selected={validUntil}
|
||||
onChange={(date) => setValidUntil(date ? moment(date).endOf("day").toDate() : undefined)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full justify-end items-center gap-8 mt-8">
|
||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="w-full max-w-[200px]" onClick={submit} disabled={!percentage || !domain}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function DiscountList({ user }: { user: User }) {
|
||||
const [selectedDiscounts, setSelectedDiscounts] = useState<string[]>([]);
|
||||
export default function DiscountList({user}: {user: User}) {
|
||||
const [selectedDiscounts, setSelectedDiscounts] = useState<string[]>([]);
|
||||
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingDiscount, setEditingDiscount] = useState<Discount>();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingDiscount, setEditingDiscount] = useState<Discount>();
|
||||
|
||||
const [filteredDiscounts, setFilteredDiscounts] = useState<Discount[]>([]);
|
||||
const [filteredDiscounts, setFilteredDiscounts] = useState<Discount[]>([]);
|
||||
|
||||
const { users } = useUsers();
|
||||
const { discounts, reload } = useDiscounts();
|
||||
const {users} = useUsers();
|
||||
const {discounts, reload} = useDiscounts();
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredDiscounts(discounts);
|
||||
}, [discounts]);
|
||||
useEffect(() => {
|
||||
setFilteredDiscounts(discounts);
|
||||
}, [discounts]);
|
||||
|
||||
const toggleDiscount = (id: string) => {
|
||||
setSelectedDiscounts((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
const toggleDiscount = (id: string) => {
|
||||
setSelectedDiscounts((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const toggleAllDiscounts = (checked: boolean) => {
|
||||
if (checked)
|
||||
return setSelectedDiscounts(filteredDiscounts.map((x) => x.id));
|
||||
const toggleAllDiscounts = (checked: boolean) => {
|
||||
if (checked) return setSelectedDiscounts(filteredDiscounts.map((x) => x.id));
|
||||
|
||||
return setSelectedDiscounts([]);
|
||||
};
|
||||
return setSelectedDiscounts([]);
|
||||
};
|
||||
|
||||
const deleteDiscounts = async (discounts: string[]) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to delete these ${discounts.length} discount(s)?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
const deleteDiscounts = async (discounts: string[]) => {
|
||||
if (!confirm(`Are you sure you want to delete these ${discounts.length} discount(s)?`)) return;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
discounts.forEach((code) => params.append("discount", code));
|
||||
const params = new URLSearchParams();
|
||||
discounts.forEach((code) => params.append("discount", code));
|
||||
|
||||
axios
|
||||
.delete(`/api/discounts?${params.toString()}`)
|
||||
.then(() => toast.success(`Deleted the discount(s)!`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Discount not found!");
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.delete(`/api/discounts?${params.toString()}`)
|
||||
.then(() => toast.success(`Deleted the discount(s)!`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Discount not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this discount!");
|
||||
return;
|
||||
}
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this discount!");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
|
||||
const deleteDiscount = async (discount: Discount) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to delete this "${discount.id}" discount?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
const deleteDiscount = async (discount: Discount) => {
|
||||
if (!confirm(`Are you sure you want to delete this "${discount.id}" discount?`)) return;
|
||||
|
||||
axios
|
||||
.delete(`/api/discounts/${discount.id}`)
|
||||
.then(() => toast.success(`Deleted the "${discount.id}" discount`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Code not found!");
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.delete(`/api/discounts/${discount.id}`)
|
||||
.then(() => toast.success(`Deleted the "${discount.id}" discount`))
|
||||
.catch((reason) => {
|
||||
if (reason.response.status === 404) {
|
||||
toast.error("Code not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this discount!");
|
||||
return;
|
||||
}
|
||||
if (reason.response.status === 403) {
|
||||
toast.error("You do not have permission to delete this discount!");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
};
|
||||
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("id", {
|
||||
id: "id",
|
||||
header: () => (
|
||||
<Checkbox
|
||||
disabled={filteredDiscounts.length === 0}
|
||||
isChecked={
|
||||
selectedDiscounts.length === filteredDiscounts.length &&
|
||||
filteredDiscounts.length > 0
|
||||
}
|
||||
onChange={(checked) => toggleAllDiscounts(checked)}
|
||||
>
|
||||
{""}
|
||||
</Checkbox>
|
||||
),
|
||||
cell: (info) => (
|
||||
<Checkbox
|
||||
isChecked={selectedDiscounts.includes(info.getValue())}
|
||||
onChange={() => toggleDiscount(info.getValue())}
|
||||
>
|
||||
{""}
|
||||
</Checkbox>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("id", {
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("domain", {
|
||||
header: "Domain",
|
||||
cell: (info) => `@${info.getValue()}`,
|
||||
}),
|
||||
columnHelper.accessor("percentage", {
|
||||
header: "Percentage",
|
||||
cell: (info) => `${info.getValue()}%`,
|
||||
}),
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
cell: ({ row }: { row: { original: Discount } }) => {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<div
|
||||
data-tip="Delete"
|
||||
className="cursor-pointer tooltip"
|
||||
onClick={() => {
|
||||
setEditingDiscount(row.original);
|
||||
}}
|
||||
>
|
||||
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
<div
|
||||
data-tip="Delete"
|
||||
className="cursor-pointer tooltip"
|
||||
onClick={() => deleteDiscount(row.original)}
|
||||
>
|
||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
const defaultColumns = [
|
||||
columnHelper.accessor("id", {
|
||||
id: "id",
|
||||
header: () => (
|
||||
<Checkbox
|
||||
disabled={filteredDiscounts.length === 0}
|
||||
isChecked={selectedDiscounts.length === filteredDiscounts.length && filteredDiscounts.length > 0}
|
||||
onChange={(checked) => toggleAllDiscounts(checked)}>
|
||||
{""}
|
||||
</Checkbox>
|
||||
),
|
||||
cell: (info) => (
|
||||
<Checkbox isChecked={selectedDiscounts.includes(info.getValue())} onChange={() => toggleDiscount(info.getValue())}>
|
||||
{""}
|
||||
</Checkbox>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("id", {
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("domain", {
|
||||
header: "Domain",
|
||||
cell: (info) => `@${info.getValue()}`,
|
||||
}),
|
||||
columnHelper.accessor("percentage", {
|
||||
header: "Percentage",
|
||||
cell: (info) => `${info.getValue()}%`,
|
||||
}),
|
||||
columnHelper.accessor("validUntil", {
|
||||
header: "Valid Until",
|
||||
cell: (info) => (info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : ""),
|
||||
}),
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
cell: ({row}: {row: {original: Discount}}) => {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<div
|
||||
data-tip="Delete"
|
||||
className="cursor-pointer tooltip"
|
||||
onClick={() => {
|
||||
setEditingDiscount(row.original);
|
||||
}}>
|
||||
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteDiscount(row.original)}>
|
||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredDiscounts,
|
||||
columns: defaultColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
const table = useReactTable({
|
||||
data: filteredDiscounts,
|
||||
columns: defaultColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
setIsCreating(false);
|
||||
setEditingDiscount(undefined);
|
||||
reload();
|
||||
};
|
||||
const closeModal = () => {
|
||||
setIsCreating(false);
|
||||
setEditingDiscount(undefined);
|
||||
reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isCreating || !!editingDiscount}
|
||||
onClose={closeModal}
|
||||
title={
|
||||
editingDiscount ? `Editing ${editingDiscount.id}` : "New Discount"
|
||||
}
|
||||
>
|
||||
<DiscountCreator onClose={closeModal} discount={editingDiscount} />
|
||||
</Modal>
|
||||
<div className="flex items-center justify-end pb-4 pt-1">
|
||||
<div className="flex gap-4 items-center">
|
||||
<span>{selectedDiscounts.length} code(s) selected</span>
|
||||
<Button
|
||||
disabled={selectedDiscounts.length === 0}
|
||||
variant="outline"
|
||||
color="red"
|
||||
className="!py-1 px-10"
|
||||
onClick={() => deleteDiscounts(selectedDiscounts)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th className="p-4 text-left" key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="px-2">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
|
||||
key={row.id}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td className="px-4 py-2" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white"
|
||||
>
|
||||
New Discount
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isCreating || !!editingDiscount}
|
||||
onClose={closeModal}
|
||||
title={editingDiscount ? `Editing ${editingDiscount.id}` : "New Discount"}>
|
||||
<DiscountCreator onClose={closeModal} discount={editingDiscount} />
|
||||
</Modal>
|
||||
<div className="flex items-center justify-end pb-4 pt-1">
|
||||
<div className="flex gap-4 items-center">
|
||||
<span>{selectedDiscounts.length} code(s) selected</span>
|
||||
<Button
|
||||
disabled={selectedDiscounts.length === 0}
|
||||
variant="outline"
|
||||
color="red"
|
||||
className="!py-1 px-10"
|
||||
onClick={() => deleteDiscounts(selectedDiscounts)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th className="p-4 text-left" key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="px-2">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td className="px-4 py-2" key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="w-full py-2 bg-mti-purple-light hover:bg-mti-purple transition ease-in-out duration-300 text-white">
|
||||
New Discount
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function ExamList({user}: {user: User}) {
|
||||
};
|
||||
|
||||
const getTotalExercises = (exam: Exam) => {
|
||||
if (exam.module === "reading" || exam.module === "listening") {
|
||||
if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") {
|
||||
return countExercises(exam.parts.flatMap((x) => x.exercises));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 filteredUsers = emailUsers.filter(
|
||||
(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")) ||
|
||||
(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}) {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
@@ -197,10 +197,10 @@ export default function GroupList({user}: {user: User}) {
|
||||
const [filterByUser, setFilterByUser] = useState(false);
|
||||
|
||||
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(() => {
|
||||
if (user && (user.type === "corporate" || user.type === "teacher")) {
|
||||
if (user && (['corporate', 'teacher', 'mastercorporate'].includes(user.type))) {
|
||||
setFilterByUser(true);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
@@ -40,7 +40,7 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
|
||||
const [unit, setUnit] = useState<DurationUnit>(pack?.duration_unit || "months");
|
||||
|
||||
const [price, setPrice] = useState(pack?.price || 0);
|
||||
const [currency, setCurrency] = useState<string>(pack?.currency || "EUR");
|
||||
const [currency, setCurrency] = useState<string>(pack?.currency || "OMR");
|
||||
|
||||
const submit = () => {
|
||||
(pack ? axios.patch : axios.post)(pack ? `/api/packages/${pack.id}` : "/api/packages", {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 {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 {getExamById} from "@/utils/exams";
|
||||
import {playSound} from "@/utils/sound";
|
||||
@@ -8,26 +21,48 @@ import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize, sample} from "lodash";
|
||||
import {useRouter} from "next/router";
|
||||
import {useState} from "react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsArrowRepeat, BsCheck, BsPencilSquare, BsX} from "react-icons/bs";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import {toast} from "react-toastify";
|
||||
import {v4} from "uuid";
|
||||
|
||||
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 [isEditing, setIsEditing] = useState(false);
|
||||
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 (
|
||||
<div key={question.id} className="flex flex-col gap-1">
|
||||
<span className="font-semibold">
|
||||
{question.id}. {question.prompt}{" "}
|
||||
<>
|
||||
{question.id}. <span>{renderPrompt(question.prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||
</>
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
{question.options.map((option, index) => (
|
||||
<span key={option.id} className={clsx(question.solution === option.id && "font-bold")}>
|
||||
<span className={clsx("font-semibold", question.solution === option.id ? "text-mti-green-light" : "text-ielts-level")}>
|
||||
<span key={option.id} className={clsx(answer === option.id && "font-bold")}>
|
||||
<span
|
||||
className={clsx("font-semibold", answer === option.id ? "text-mti-green-light" : "text-ielts-level")}
|
||||
onClick={() => setAnswer(option.id)}>
|
||||
({option.id})
|
||||
</span>{" "}
|
||||
{isEditing ? (
|
||||
@@ -54,7 +89,7 @@ const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
onUpdate({...question, options});
|
||||
onUpdate({...question, options, solution: answer});
|
||||
setIsEditing(false);
|
||||
}}
|
||||
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 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) => {
|
||||
if (!exam) return;
|
||||
if (!section) return;
|
||||
|
||||
const updatedExam = {
|
||||
...exam,
|
||||
exercises: exam.exercises.map((x) => ({
|
||||
...section,
|
||||
exercises: section.part?.exercises.map((x) => ({
|
||||
...x,
|
||||
questions: (x as MultipleChoiceExercise).questions.map((q) => (q.id === question.id ? question : q)),
|
||||
})),
|
||||
};
|
||||
console.log(updatedExam);
|
||||
setExam(updatedExam as any);
|
||||
setSection(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 (
|
||||
<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">
|
||||
<button
|
||||
onClick={generate}
|
||||
disabled={isLoading}
|
||||
className={clsx(
|
||||
"bg-ielts-level/70 border border-ielts-level text-white w-full px-6 py-6 rounded-xl h-[70px]",
|
||||
"hover:bg-ielts-level disabled:bg-ielts-level/40 disabled:cursor-not-allowed",
|
||||
"transition ease-in-out duration-300",
|
||||
)}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||
</div>
|
||||
) : (
|
||||
"Generate"
|
||||
)}
|
||||
</button>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex gap-4 w-full">
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Exercise Type</label>
|
||||
<Select
|
||||
options={Object.keys(TYPES).map((key) => ({value: key, label: TYPES[key]}))}
|
||||
onChange={(e) => setSection({...section, type: e!.value!})}
|
||||
value={{value: section?.type || "multiple_choice_4", label: TYPES[section?.type || "multiple_choice_4"]}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Number of Questions</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="Number of Questions"
|
||||
onChange={(v) => setSection({...section, quantity: parseInt(v)})}
|
||||
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>
|
||||
{isLoading && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
{exam && (
|
||||
{section?.part && (
|
||||
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide h-full">
|
||||
{exam.exercises
|
||||
.filter((x) => x.type === "multipleChoice")
|
||||
.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>
|
||||
);
|
||||
})}
|
||||
{section.part.context && <div>{section.part.context}</div>}
|
||||
{section.part.exercises.map(renderExercise)}
|
||||
</div>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
@@ -167,7 +234,14 @@ const LevelGeneration = () => {
|
||||
const [generatedExam, setGeneratedExam] = useState<LevelExam>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [resultingExam, setResultingExam] = useState<LevelExam>();
|
||||
const [timer, setTimer] = useState(10);
|
||||
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();
|
||||
|
||||
@@ -190,6 +264,156 @@ const LevelGeneration = () => {
|
||||
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: true,
|
||||
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 = () => {
|
||||
if (!generatedExam) {
|
||||
toast.error("Please generate all tasks before submitting");
|
||||
@@ -197,12 +421,10 @@ const LevelGeneration = () => {
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const exam: LevelExam = {
|
||||
|
||||
const exam = {
|
||||
...generatedExam,
|
||||
isDiagnostic: false,
|
||||
minTimer: 25,
|
||||
module: "level",
|
||||
id: v4(),
|
||||
parts: generatedExam.parts.map((p, i) => ({...p, exercises: parts[i].part!.exercises})),
|
||||
};
|
||||
|
||||
axios
|
||||
@@ -224,32 +446,55 @@ const LevelGeneration = () => {
|
||||
|
||||
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">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||
<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)}
|
||||
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||
/>
|
||||
</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>
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
|
||||
<Tab
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-level/70",
|
||||
"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>
|
||||
{Array.from(Array(numberOfParts), (_, index) => index).map((index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
className={({selected}) =>
|
||||
clsx(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-level/70",
|
||||
"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",
|
||||
)
|
||||
}>
|
||||
Part {index + 1}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
<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.Group>
|
||||
<div className="w-full flex justify-end gap-4">
|
||||
@@ -265,6 +510,24 @@ const LevelGeneration = () => {
|
||||
Perform Exam
|
||||
</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
|
||||
disabled={!generatedExam || isLoading}
|
||||
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 Select from "@/components/Low/Select";
|
||||
import {Difficulty, Exercise, ListeningExam} from "@/interfaces/exam";
|
||||
@@ -10,27 +11,50 @@ import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize, sample} from "lodash";
|
||||
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 {toast} from "react-toastify";
|
||||
import WriteBlanksEdit from "@/components/Generation/write.blanks.edit";
|
||||
import {generate} from "random-words";
|
||||
|
||||
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 = ({
|
||||
part,
|
||||
types,
|
||||
difficulty,
|
||||
availableTypes,
|
||||
index,
|
||||
setPart,
|
||||
updatePart,
|
||||
}: {
|
||||
part?: ListeningPart;
|
||||
difficulty: Difficulty;
|
||||
types: string[];
|
||||
availableTypes: {type: string; label: string}[];
|
||||
index: number;
|
||||
setPart: (part?: ListeningPart) => void;
|
||||
updatePart: Dispatch<SetStateAction<ListeningPart | undefined>>;
|
||||
}) => {
|
||||
const [topic, setTopic] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [types, setTypes] = useState<string[]>([]);
|
||||
|
||||
const generate = () => {
|
||||
const url = new URLSearchParams();
|
||||
@@ -55,8 +79,85 @@ const PartTab = ({
|
||||
.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 (
|
||||
<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">
|
||||
<Input type="text" placeholder="Grand Canyon..." name="topic" label="Topic" onChange={setTopic} roundness="xl" defaultValue={topic} />
|
||||
<button
|
||||
@@ -85,26 +186,29 @@ const PartTab = ({
|
||||
</div>
|
||||
)}
|
||||
{part && (
|
||||
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide">
|
||||
<div className="flex gap-4">
|
||||
{part.exercises.map((x) => (
|
||||
<span className="rounded-xl bg-white border border-ielts-listening p-1 px-4" key={x.id}>
|
||||
{x.type && convertCamelCaseToReadable(x.type)}
|
||||
</span>
|
||||
))}
|
||||
</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", " ")}
|
||||
<>
|
||||
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide">
|
||||
<div className="flex gap-4">
|
||||
{part.exercises.map((x) => (
|
||||
<span className="rounded-xl bg-white border border-ielts-listening p-1 px-4" key={x.id}>
|
||||
{x.type && convertCamelCaseToReadable(x.type)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{renderExercises()}
|
||||
</>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
);
|
||||
@@ -132,7 +236,6 @@ const ListeningGeneration = () => {
|
||||
const [minTimer, setMinTimer] = useState(30);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [resultingExam, setResultingExam] = useState<ListeningExam>();
|
||||
const [types, setTypes] = useState<string[]>([]);
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -145,20 +248,11 @@ const ListeningGeneration = () => {
|
||||
setMinTimer(sum > 0 ? sum : 5);
|
||||
}, [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 setExams = useExamStore((state) => state.setExams);
|
||||
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 parts = [part1, part2, part3, part4].filter((x) => !!x);
|
||||
console.log({parts});
|
||||
@@ -167,11 +261,16 @@ const ListeningGeneration = () => {
|
||||
setIsLoading(true);
|
||||
|
||||
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) => {
|
||||
playSound("sent");
|
||||
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);
|
||||
|
||||
setPart1(undefined);
|
||||
@@ -179,7 +278,6 @@ const ListeningGeneration = () => {
|
||||
setPart3(undefined);
|
||||
setPart4(undefined);
|
||||
setDifficulty(sample(DIFFICULTIES)!);
|
||||
setTypes([]);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
@@ -220,34 +318,16 @@ const ListeningGeneration = () => {
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||
<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)}
|
||||
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||
disabled={!!part1 || !!part2 || !!part3 || !!part4}
|
||||
/>
|
||||
</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.List className="flex space-x-1 rounded-xl bg-ielts-listening/20 p-1">
|
||||
<Tab
|
||||
@@ -297,12 +377,36 @@ const ListeningGeneration = () => {
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
{[
|
||||
{part: part1, setPart: setPart1},
|
||||
{part: part2, setPart: setPart2},
|
||||
{part: part3, setPart: setPart3},
|
||||
{part: part4, setPart: setPart4},
|
||||
].map(({part, setPart}, index) => (
|
||||
<PartTab part={part} difficulty={difficulty} types={types} index={index + 1} key={index} setPart={setPart} />
|
||||
{
|
||||
part: part1,
|
||||
setPart: setPart1,
|
||||
types: [MULTIPLE_CHOICE, WRITE_BLANKS_QUESTIONS, WRITE_BLANKS_FILL, WRITE_BLANKS_FORM],
|
||||
},
|
||||
{
|
||||
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.Group>
|
||||
|
||||
@@ -1,37 +1,53 @@
|
||||
import Input from "@/components/Low/Input";
|
||||
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 {getExamById} from "@/utils/exams";
|
||||
import {playSound} from "@/utils/sound";
|
||||
import {convertCamelCaseToReadable} from "@/utils/string";
|
||||
import {generate} from "random-words";
|
||||
import {Tab} from "@headlessui/react";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize, sample} from "lodash";
|
||||
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 {toast} from "react-toastify";
|
||||
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 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 = ({
|
||||
part,
|
||||
types,
|
||||
difficulty,
|
||||
index,
|
||||
setPart,
|
||||
updatePart,
|
||||
}: {
|
||||
part?: ReadingPart;
|
||||
types: string[];
|
||||
index: number;
|
||||
difficulty: Difficulty;
|
||||
setPart: (part?: ReadingPart) => void;
|
||||
updatePart: Dispatch<SetStateAction<ReadingPart | undefined>>;
|
||||
}) => {
|
||||
const [topic, setTopic] = useState("");
|
||||
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 url = new URLSearchParams();
|
||||
@@ -45,6 +61,8 @@ const PartTab = ({
|
||||
axios
|
||||
.get(`/api/exam/reading/generate/reading_passage_${index}${topic || types ? `?${url.toString()}` : ""}`)
|
||||
.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.");
|
||||
setPart(result.data);
|
||||
@@ -56,8 +74,143 @@ const PartTab = ({
|
||||
.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 (
|
||||
<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">
|
||||
<Input type="text" placeholder="Grand Canyon..." name="topic" label="Topic" onChange={setTopic} roundness="xl" defaultValue={topic} />
|
||||
<button
|
||||
@@ -86,17 +239,20 @@ const PartTab = ({
|
||||
</div>
|
||||
)}
|
||||
{part && (
|
||||
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide">
|
||||
<div className="flex gap-4">
|
||||
{part.exercises.map((x) => (
|
||||
<span className="rounded-xl bg-white border border-ielts-reading p-1 px-4" key={x.id}>
|
||||
{x.type && convertCamelCaseToReadable(x.type)}
|
||||
</span>
|
||||
))}
|
||||
<>
|
||||
<div className="flex flex-col gap-2 w-full overflow-y-scroll scrollbar-hide">
|
||||
<div className="flex gap-4">
|
||||
{part.exercises.map((x) => (
|
||||
<span className="rounded-xl bg-white border border-ielts-reading p-1 px-4" key={x.id}>
|
||||
{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>
|
||||
<h3 className="text-xl font-semibold">{part.text.title}</h3>
|
||||
<span className="w-full h-96">{part.text.content}</span>
|
||||
</div>
|
||||
{renderExercises()}
|
||||
</>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
);
|
||||
@@ -107,7 +263,6 @@ const ReadingGeneration = () => {
|
||||
const [part2, setPart2] = useState<ReadingPart>();
|
||||
const [part3, setPart3] = useState<ReadingPart>();
|
||||
const [minTimer, setMinTimer] = useState(60);
|
||||
const [types, setTypes] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [resultingExam, setResultingExam] = useState<ReadingExam>();
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||
@@ -122,15 +277,6 @@ const ReadingGeneration = () => {
|
||||
const setExams = useExamStore((state) => state.setExams);
|
||||
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 exam = await getExamById("reading", examId.trim());
|
||||
if (!exam) {
|
||||
@@ -160,7 +306,7 @@ const ReadingGeneration = () => {
|
||||
isDiagnostic: false,
|
||||
minTimer,
|
||||
module: "reading",
|
||||
id: v4(),
|
||||
id: generate({minLength: 4, maxLength: 8, min: 3, max: 5, join: " ", formatter: capitalize}),
|
||||
type: "academic",
|
||||
variant: parts.length === 3 ? "full" : "partial",
|
||||
difficulty,
|
||||
@@ -171,7 +317,7 @@ const ReadingGeneration = () => {
|
||||
.then((result) => {
|
||||
playSound("sent");
|
||||
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);
|
||||
|
||||
setPart1(undefined);
|
||||
@@ -179,7 +325,6 @@ const ReadingGeneration = () => {
|
||||
setPart3(undefined);
|
||||
setDifficulty(sample(DIFFICULTIES)!);
|
||||
setMinTimer(60);
|
||||
setTypes([]);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
@@ -204,32 +349,16 @@ const ReadingGeneration = () => {
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||
<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)}
|
||||
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||
disabled={!!part1 || !!part2 || !!part3}
|
||||
/>
|
||||
</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.List className="flex space-x-1 rounded-xl bg-ielts-reading/20 p-1">
|
||||
<Tab
|
||||
@@ -272,7 +401,7 @@ const ReadingGeneration = () => {
|
||||
{part: part2, setPart: setPart2},
|
||||
{part: part3, setPart: setPart3},
|
||||
].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.Group>
|
||||
|
||||
@@ -12,7 +12,8 @@ import clsx from "clsx";
|
||||
import {capitalize, sample, uniq} from "lodash";
|
||||
import moment from "moment";
|
||||
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 {toast} from "react-toastify";
|
||||
import {v4} from "uuid";
|
||||
@@ -24,11 +25,13 @@ const PartTab = ({
|
||||
index,
|
||||
difficulty,
|
||||
setPart,
|
||||
updatePart,
|
||||
}: {
|
||||
part?: SpeakingPart;
|
||||
difficulty: Difficulty;
|
||||
index: number;
|
||||
setPart: (part?: SpeakingPart) => void;
|
||||
updatePart: Dispatch<SetStateAction<SpeakingPart | undefined>>;
|
||||
}) => {
|
||||
const [gender, setGender] = useState<"male" | "female">("male");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -45,6 +48,7 @@ const PartTab = ({
|
||||
.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.");
|
||||
console.log(result.data);
|
||||
setPart(result.data);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -54,7 +58,7 @@ const PartTab = ({
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
const generateVideo = () => {
|
||||
const generateVideo = async () => {
|
||||
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.");
|
||||
|
||||
@@ -64,13 +68,22 @@ const PartTab = ({
|
||||
const initialTime = moment();
|
||||
|
||||
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) => {
|
||||
const isError = typeof result.data === "string" || moment().diff(initialTime, "seconds") < 60;
|
||||
|
||||
playSound(isError ? "error" : "check");
|
||||
console.log(result.data);
|
||||
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) => {
|
||||
toast.error("Something went wrong!");
|
||||
@@ -139,7 +152,9 @@ const PartTab = ({
|
||||
)}
|
||||
{part && !isLoading && (
|
||||
<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.questions && (
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -166,6 +181,28 @@ const PartTab = ({
|
||||
<b>Instructor:</b> {part.avatar.name} - {capitalize(part.avatar.gender)}
|
||||
</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>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
@@ -177,6 +214,8 @@ interface SpeakingPart {
|
||||
question?: string;
|
||||
questions?: string[];
|
||||
topic: string;
|
||||
first_topic?: string;
|
||||
second_topic?: string;
|
||||
result?: SpeakingExercise | InteractiveSpeakingExercise;
|
||||
gender?: "male" | "female";
|
||||
avatar?: (typeof AVATARS)[number];
|
||||
@@ -208,10 +247,18 @@ const SpeakingGeneration = () => {
|
||||
|
||||
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 = {
|
||||
id: v4(),
|
||||
id: generate({minLength: 4, maxLength: 8, min: 3, max: 5, join: " ", formatter: capitalize}),
|
||||
isDiagnostic: false,
|
||||
exercises: [part1?.result, part2?.result, part3?.result].filter((x) => !!x) as (SpeakingExercise | InteractiveSpeakingExercise)[],
|
||||
exercises: exercises as (SpeakingExercise | InteractiveSpeakingExercise)[],
|
||||
minTimer,
|
||||
variant: minTimer >= 14 ? "full" : "partial",
|
||||
module: "speaking",
|
||||
@@ -223,7 +270,7 @@ const SpeakingGeneration = () => {
|
||||
.then((result) => {
|
||||
playSound("sent");
|
||||
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);
|
||||
|
||||
setPart1(undefined);
|
||||
@@ -271,7 +318,10 @@ const SpeakingGeneration = () => {
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
|
||||
<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)}
|
||||
value={{value: difficulty, label: capitalize(difficulty)}}
|
||||
disabled={!!part1 || !!part2 || !!part3}
|
||||
@@ -321,7 +371,7 @@ const SpeakingGeneration = () => {
|
||||
{part: part2, setPart: setPart2},
|
||||
{part: part3, setPart: setPart3},
|
||||
].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.Group>
|
||||
|
||||
@@ -9,6 +9,7 @@ import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize, sample} from "lodash";
|
||||
import {useRouter} from "next/router";
|
||||
import {generate} from "random-words";
|
||||
import {useEffect, useState} from "react";
|
||||
import {BsArrowRepeat, BsCheck} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
@@ -151,7 +152,7 @@ const WritingGeneration = () => {
|
||||
minTimer,
|
||||
module: "writing",
|
||||
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",
|
||||
difficulty,
|
||||
};
|
||||
@@ -160,6 +161,7 @@ const WritingGeneration = () => {
|
||||
.post(`/api/exam/writing`, exam)
|
||||
.then((result) => {
|
||||
console.log(`Generated Exam ID: ${result.data.id}`);
|
||||
toast.success(`Generated Exam ID: ${result.data.id}`);
|
||||
playSound("sent");
|
||||
toast.success("This new exam has been generated successfully! Check the ID in our browser's console.");
|
||||
setResultingExam(result.data);
|
||||
|
||||
@@ -7,7 +7,6 @@ import {User} from "@/interfaces/user";
|
||||
import clsx from "clsx";
|
||||
import {capitalize} from "lodash";
|
||||
import {useEffect, useState} from "react";
|
||||
import getSymbolFromCurrency from "currency-symbol-map";
|
||||
import useInvites from "@/hooks/useInvites";
|
||||
import {BsArrowRepeat} from "react-icons/bs";
|
||||
import InviteCard from "@/components/Medium/InviteCard";
|
||||
@@ -15,15 +14,15 @@ import {useRouter} from "next/router";
|
||||
import {ToastContainer} from "react-toastify";
|
||||
import useDiscounts from "@/hooks/useDiscounts";
|
||||
import PaymobPayment from "@/components/PaymobPayment";
|
||||
import moment from "moment";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
hasExpired?: boolean;
|
||||
clientID: string;
|
||||
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 [appliedDiscount, setAppliedDiscount] = useState(0);
|
||||
|
||||
@@ -40,7 +39,7 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
||||
if (userDiscounts.length === 0) return;
|
||||
|
||||
const biggestDiscount = [...userDiscounts].sort((a, b) => b.percentage - a.percentage).shift();
|
||||
if (!biggestDiscount) return;
|
||||
if (!biggestDiscount || (biggestDiscount.validUntil && moment(biggestDiscount.validUntil).isBefore(moment()))) return;
|
||||
|
||||
setAppliedDiscount(biggestDiscount.percentage);
|
||||
}, [discounts, user]);
|
||||
@@ -121,26 +120,22 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-start gap-2">
|
||||
{!appliedDiscount && (
|
||||
{appliedDiscount === 0 && (
|
||||
<span className="text-2xl">
|
||||
{p.price}
|
||||
{getSymbolFromCurrency(p.currency)}
|
||||
{p.price} {p.currency}
|
||||
</span>
|
||||
)}
|
||||
{appliedDiscount && (
|
||||
{appliedDiscount > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl line-through">
|
||||
{p.price}
|
||||
{getSymbolFromCurrency(p.currency)}
|
||||
{p.price} {p.currency}
|
||||
</span>
|
||||
<span className="text-2xl text-mti-red-light">
|
||||
{(p.price - p.price * (appliedDiscount / 100)).toFixed(2)}
|
||||
{getSymbolFromCurrency(p.currency)}
|
||||
{(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} {p.currency}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<PaymobPayment
|
||||
key={clientID}
|
||||
user={user}
|
||||
setIsPaymentLoading={setIsLoading}
|
||||
onSuccess={() => {
|
||||
@@ -177,11 +172,9 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-start gap-2">
|
||||
<span className="text-2xl">
|
||||
{user.corporateInformation.payment.value}
|
||||
{getSymbolFromCurrency(user.corporateInformation.payment.currency)}
|
||||
{user.corporateInformation.payment.value} {user.corporateInformation.payment.currency}
|
||||
</span>
|
||||
<PaymobPayment
|
||||
key={clientID}
|
||||
user={user}
|
||||
setIsPaymentLoading={setIsLoading}
|
||||
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 {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {uuidv4} from "@firebase/util";
|
||||
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ const generateExams = async (
|
||||
|
||||
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||
const {
|
||||
examIDs,
|
||||
selectedModules,
|
||||
assignees,
|
||||
// Generate multiple true would generate an unique exam for each user
|
||||
@@ -111,6 +112,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||
instructorGender,
|
||||
...body
|
||||
} = req.body as {
|
||||
examIDs?: {id: string; module: Module}[];
|
||||
selectedModules: Module[];
|
||||
assignees: string[];
|
||||
generateMultiple: Boolean;
|
||||
@@ -121,7 +123,9 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||
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) {
|
||||
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);
|
||||
|
||||
console.log("🌱 - Still processing");
|
||||
const backendRequest = await evaluate({answers: uploadingAudios});
|
||||
const backendRequest = await evaluate({answers: uploadingAudios}, fields.variant);
|
||||
console.log("🌱 - Process complete");
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async function evaluate(body: {answers: object[]}): Promise<AxiosResponse> {
|
||||
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/speaking_task_3`, body, {
|
||||
async function evaluate(body: {answers: object[]}, variant?: "initial" | "final"): Promise<AxiosResponse> {
|
||||
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/speaking_task_${variant === "initial" ? "1" : "3"}`, body, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
const audioFile = files.audio;
|
||||
const audioFileRef = ref(storage, `speaking_recordings/${fields.id}.wav`);
|
||||
const task = parseInt(fields.task.toString());
|
||||
|
||||
const binary = fs.readFileSync((audioFile as any).path).buffer;
|
||||
const snapshot = await uploadBytes(audioFileRef, binary);
|
||||
@@ -39,7 +40,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
res.status(200).json(null);
|
||||
|
||||
console.log("🌱 - Still processing");
|
||||
const backendRequest = await evaluate({answers: [{question: fields.question, answer: path}]});
|
||||
const backendRequest = await evaluate({answer: path, question: fields.question}, task);
|
||||
console.log("🌱 - Process complete");
|
||||
|
||||
const correspondingStat = await getCorrespondingStat(fields.id, 1);
|
||||
@@ -76,14 +77,14 @@ async function getCorrespondingStat(id: string, index: number): Promise<Stat> {
|
||||
return getCorrespondingStat(id, index + 1);
|
||||
}
|
||||
|
||||
async function evaluate(body: {answers: object[]}): Promise<AxiosResponse> {
|
||||
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/speaking_task_3`, body, {
|
||||
async function evaluate(body: {answer: string; question: string}, task: number): Promise<AxiosResponse> {
|
||||
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/speaking_task_2`, body, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (typeof backendRequest.data === "string") return evaluate(body);
|
||||
if (typeof backendRequest.data === "string") return evaluate(body, task);
|
||||
return backendRequest;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ async function handler(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.type !== "developer") return res.status(403).json({ok: false});
|
||||
|
||||
const {endpoint, topic, exercises, difficulty} = req.query as {
|
||||
module: Module;
|
||||
@@ -48,7 +47,6 @@ async function get(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.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 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);
|
||||
}
|
||||
@@ -30,29 +30,74 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
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) {
|
||||
const { admin, participant } = req.query as {
|
||||
admin: string;
|
||||
participant: string;
|
||||
};
|
||||
|
||||
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[];
|
||||
if (req.session?.user?.type === "mastercorporate") {
|
||||
try {
|
||||
const masterCorporateGroups = await getGroupsForUser(admin, participant);
|
||||
const corporatesFromMaster = masterCorporateGroups
|
||||
.filter((g) => g.name === "Corporate")
|
||||
.flatMap((g) => g.participants);
|
||||
|
||||
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) {
|
||||
@@ -60,8 +105,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
await Promise.all(
|
||||
body.participants.map(
|
||||
async (p) => await updateExpiryDateOnGroup(p, body.admin),
|
||||
),
|
||||
async (p) => await updateExpiryDateOnGroup(p, body.admin)
|
||||
)
|
||||
);
|
||||
|
||||
await setDoc(doc(db, "groups", v4()), {
|
||||
|
||||
@@ -41,7 +41,12 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
const response = await axios.post<IntentionResult>(
|
||||
"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}`}},
|
||||
);
|
||||
const intentionResult = response.data;
|
||||
|
||||
@@ -22,8 +22,9 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
const transactionResult = req.body as TransactionResult;
|
||||
const authToken = await authenticatePaymob();
|
||||
|
||||
console.log("WEBHOOK: ", transactionResult);
|
||||
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 {
|
||||
userID: string;
|
||||
@@ -42,9 +43,9 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
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()), {
|
||||
createdAt: new Date().toISOString(),
|
||||
currency: transactionResult.transaction.currency,
|
||||
@@ -64,10 +65,16 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
const participants = (await Promise.all(
|
||||
groups.flatMap((x) => x.participants).map(async (x) => ({...(await getDoc(doc(db, "users", x))).data(), id: x})),
|
||||
)) 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) {
|
||||
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,
|
||||
};
|
||||
|
||||
const defaultCorporateGroup: Group = {
|
||||
admin: userId,
|
||||
id: v4(),
|
||||
name: "Corporate",
|
||||
participants: [],
|
||||
disableEditing: true,
|
||||
};
|
||||
|
||||
await setDoc(doc(db, "users", userId), user);
|
||||
await setDoc(doc(db, "groups", defaultTeachersGroup.id), defaultTeachersGroup);
|
||||
await setDoc(doc(db, "groups", defaultStudentsGroup.id), defaultStudentsGroup);
|
||||
await setDoc(doc(db, "groups", defaultCorporateGroup.id), defaultCorporateGroup);
|
||||
|
||||
req.session.user = {...user, id: userId};
|
||||
await req.session.save();
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||
import {app, adminApp} from "@/firebase";
|
||||
import {Group, User} from "@/interfaces/user";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {collection, deleteDoc, 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 { PERMISSIONS } from "@/constants/userPermissions";
|
||||
import { app, adminApp } from "@/firebase";
|
||||
import { Group, User } from "@/interfaces/user";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import {
|
||||
collection,
|
||||
deleteDoc,
|
||||
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 auth = getAuth(adminApp);
|
||||
@@ -13,89 +24,132 @@ const auth = getAuth(adminApp);
|
||||
export default withIronSessionApiRoute(user, sessionOptions);
|
||||
|
||||
async function user(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return get(req, res);
|
||||
if (req.method === "DELETE") return del(req, res);
|
||||
if (req.method === "GET") return get(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) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ ok: false });
|
||||
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));
|
||||
if (!docUser.exists()) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
const docUser = await getDoc(doc(db, "users", req.session.user.id));
|
||||
if (!docUser.exists()) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = docUser.data() as User;
|
||||
const user = docUser.data() as User;
|
||||
|
||||
const docTargetUser = await getDoc(doc(db, "users", id));
|
||||
if (!docTargetUser.exists()) {
|
||||
res.status(404).json({ok: false});
|
||||
return;
|
||||
}
|
||||
const docTargetUser = await getDoc(doc(db, "users", id));
|
||||
if (!docTargetUser.exists()) {
|
||||
res.status(404).json({ ok: false });
|
||||
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")) {
|
||||
res.json({ok: true});
|
||||
if (
|
||||
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)));
|
||||
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})),
|
||||
]);
|
||||
const userParticipantGroup = await getDocs(
|
||||
query(
|
||||
collection(db, "groups"),
|
||||
where("participants", "array-contains", id)
|
||||
)
|
||||
);
|
||||
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];
|
||||
if (!permission.includes(user.type)) {
|
||||
res.status(403).json({ok: false});
|
||||
return;
|
||||
}
|
||||
const permission = PERMISSIONS.deleteUser[targetUser.type];
|
||||
if (!permission.list.includes(user.type)) {
|
||||
res.status(403).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ok: true});
|
||||
res.json({ ok: true });
|
||||
|
||||
await auth.deleteUser(id);
|
||||
await deleteDoc(doc(db, "users", id));
|
||||
const userCodeDocs = await getDocs(query(collection(db, "codes"), where("userId", "==", 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 auth.deleteUser(id);
|
||||
await deleteDoc(doc(db, "users", id));
|
||||
const userCodeDocs = await getDocs(
|
||||
query(collection(db, "codes"), where("userId", "==", 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([
|
||||
...userCodeDocs.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)),
|
||||
...userParticipantGroup.docs.map(
|
||||
async (x) => await setDoc(x.ref, {participants: x.data().participants.filter((y: string) => y !== id)}, {merge: true}),
|
||||
),
|
||||
]);
|
||||
await Promise.all([
|
||||
...userCodeDocs.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)),
|
||||
...userParticipantGroup.docs.map(
|
||||
async (x) =>
|
||||
await setDoc(
|
||||
x.ref,
|
||||
{
|
||||
participants: x.data().participants.filter((y: string) => y !== id),
|
||||
},
|
||||
{ merge: true }
|
||||
)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.session.user) {
|
||||
const docUser = await getDoc(doc(db, "users", req.session.user.id));
|
||||
if (!docUser.exists()) {
|
||||
res.status(401).json(undefined);
|
||||
return;
|
||||
}
|
||||
if (req.session.user) {
|
||||
const docUser = await getDoc(doc(db, "users", req.session.user.id));
|
||||
if (!docUser.exists()) {
|
||||
res.status(401).json(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const user = docUser.data() as User;
|
||||
const user = docUser.data() as User;
|
||||
|
||||
const permissionDocs = await getPermissionDocs();
|
||||
|
||||
req.session.user = {...user, id: req.session.user.id};
|
||||
await req.session.save();
|
||||
const userWithPermissions = {
|
||||
...user,
|
||||
permissions: getPermissions(req.session.user.id, permissionDocs),
|
||||
};
|
||||
req.session.user = {
|
||||
...userWithPermissions,
|
||||
id: req.session.user.id,
|
||||
};
|
||||
await req.session.save();
|
||||
|
||||
res.json({...user, id: req.session.user.id});
|
||||
} else {
|
||||
res.status(401).json(undefined);
|
||||
}
|
||||
res.json({ ...userWithPermissions, id: req.session.user.id });
|
||||
} else {
|
||||
res.status(401).json(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (updatedUser.status || updatedUser.type === "corporate") {
|
||||
// there's no await as this does not affect the user
|
||||
propagateStatusChange(queryId, updatedUser.status);
|
||||
propagateExpiryDateChanges(queryId, user.subscriptionExpirationDate || null, updatedUser.subscriptionExpirationDate || null);
|
||||
propagateExpiryDateChanges(queryId, user.subscriptionExpirationDate, updatedUser.subscriptionExpirationDate || null);
|
||||
}
|
||||
|
||||
res.status(200).json({ok: true});
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Head from "next/head";
|
||||
import {withIronSessionSsr} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import useUser from "@/hooks/useUser";
|
||||
import {toast, ToastContainer} from "react-toastify";
|
||||
import { toast, ToastContainer } from "react-toastify";
|
||||
import Layout from "@/components/High/Layout";
|
||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||
import {useState} from "react";
|
||||
import {Module} from "@/interfaces";
|
||||
import {RadioGroup, Tab} from "@headlessui/react";
|
||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
import { useState } from "react";
|
||||
import { Module } from "@/interfaces";
|
||||
import { RadioGroup, Tab } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||
import {capitalize} from "lodash";
|
||||
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
||||
import { capitalize } from "lodash";
|
||||
import Button from "@/components/Low/Button";
|
||||
import {Exercise, ReadingPart} from "@/interfaces/exam";
|
||||
import { Exercise, ReadingPart } from "@/interfaces/exam";
|
||||
import Input from "@/components/Low/Input";
|
||||
import axios from "axios";
|
||||
import ReadingGeneration from "./(generation)/ReadingGeneration";
|
||||
@@ -21,101 +21,109 @@ import ListeningGeneration from "./(generation)/ListeningGeneration";
|
||||
import WritingGeneration from "./(generation)/WritingGeneration";
|
||||
import LevelGeneration from "./(generation)/LevelGeneration";
|
||||
import SpeakingGeneration from "./(generation)/SpeakingGeneration";
|
||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||
const user = req.session.user;
|
||||
export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
|
||||
const user = req.session.user;
|
||||
|
||||
if (!user || !user.isVerified) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/login",
|
||||
permanent: false,
|
||||
}
|
||||
};
|
||||
}
|
||||
if (!user || !user.isVerified) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/login",
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (shouldRedirectHome(user) || user.type !== "developer") {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/",
|
||||
permanent: false,
|
||||
}
|
||||
};
|
||||
}
|
||||
if (
|
||||
shouldRedirectHome(user) ||
|
||||
checkAccess(user, getTypesOfUser(["developer"]))
|
||||
) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/",
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {user: req.session.user},
|
||||
};
|
||||
return {
|
||||
props: { user: req.session.user },
|
||||
};
|
||||
}, sessionOptions);
|
||||
|
||||
export default function Generation() {
|
||||
const [module, setModule] = useState<Module>("reading");
|
||||
const [module, setModule] = useState<Module>("reading");
|
||||
|
||||
const {user} = useUser({redirectTo: "/login"});
|
||||
const { user } = useUser({ redirectTo: "/login" });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Exam Generation | EnCoach</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<ToastContainer />
|
||||
{user && (
|
||||
<Layout user={user} className="gap-6">
|
||||
<h1 className="text-2xl font-semibold">Exam Generation</h1>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Module</label>
|
||||
<RadioGroup
|
||||
value={module}
|
||||
onChange={setModule}
|
||||
className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
|
||||
{[...MODULE_ARRAY].map((x) => (
|
||||
<RadioGroup.Option value={x} key={x}>
|
||||
{({checked}) => (
|
||||
<span
|
||||
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",
|
||||
x === "reading" &&
|
||||
(!checked
|
||||
? "bg-white border-mti-gray-platinum"
|
||||
: "bg-ielts-reading/70 border-ielts-reading text-white"),
|
||||
x === "listening" &&
|
||||
(!checked
|
||||
? "bg-white border-mti-gray-platinum"
|
||||
: "bg-ielts-listening/70 border-ielts-listening text-white"),
|
||||
x === "writing" &&
|
||||
(!checked
|
||||
? "bg-white border-mti-gray-platinum"
|
||||
: "bg-ielts-writing/70 border-ielts-writing text-white"),
|
||||
x === "speaking" &&
|
||||
(!checked
|
||||
? "bg-white border-mti-gray-platinum"
|
||||
: "bg-ielts-speaking/70 border-ielts-speaking text-white"),
|
||||
x === "level" &&
|
||||
(!checked
|
||||
? "bg-white border-mti-gray-platinum"
|
||||
: "bg-ielts-level/70 border-ielts-level text-white"),
|
||||
)}>
|
||||
{capitalize(x)}
|
||||
</span>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
{module === "reading" && <ReadingGeneration />}
|
||||
{module === "listening" && <ListeningGeneration />}
|
||||
{module === "writing" && <WritingGeneration />}
|
||||
{module === "speaking" && <SpeakingGeneration />}
|
||||
{module === "level" && <LevelGeneration />}
|
||||
</Layout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Exam Generation | EnCoach</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<ToastContainer />
|
||||
{user && (
|
||||
<Layout user={user} className="gap-6">
|
||||
<h1 className="text-2xl font-semibold">Exam Generation</h1>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Module
|
||||
</label>
|
||||
<RadioGroup
|
||||
value={module}
|
||||
onChange={setModule}
|
||||
className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between"
|
||||
>
|
||||
{[...MODULE_ARRAY].map((x) => (
|
||||
<RadioGroup.Option value={x} key={x}>
|
||||
{({ checked }) => (
|
||||
<span
|
||||
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",
|
||||
x === "reading" &&
|
||||
(!checked
|
||||
? "bg-white border-mti-gray-platinum"
|
||||
: "bg-ielts-reading/70 border-ielts-reading text-white"),
|
||||
x === "listening" &&
|
||||
(!checked
|
||||
? "bg-white border-mti-gray-platinum"
|
||||
: "bg-ielts-listening/70 border-ielts-listening text-white"),
|
||||
x === "writing" &&
|
||||
(!checked
|
||||
? "bg-white border-mti-gray-platinum"
|
||||
: "bg-ielts-writing/70 border-ielts-writing text-white"),
|
||||
x === "speaking" &&
|
||||
(!checked
|
||||
? "bg-white border-mti-gray-platinum"
|
||||
: "bg-ielts-speaking/70 border-ielts-speaking text-white"),
|
||||
x === "level" &&
|
||||
(!checked
|
||||
? "bg-white border-mti-gray-platinum"
|
||||
: "bg-ielts-level/70 border-ielts-level text-white")
|
||||
)}
|
||||
>
|
||||
{capitalize(x)}
|
||||
</span>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
{module === "reading" && <ReadingGeneration />}
|
||||
{module === "listening" && <ListeningGeneration />}
|
||||
{module === "writing" && <WritingGeneration />}
|
||||
{module === "speaking" && <SpeakingGeneration />}
|
||||
{module === "level" && <LevelGeneration />}
|
||||
</Layout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,204 +1,244 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Head from "next/head";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import {BsFileEarmarkText, BsPencil, BsStar, BsBook, BsHeadphones, BsPen, BsMegaphone} from "react-icons/bs";
|
||||
import {withIronSessionSsr} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {useEffect, useState} from "react";
|
||||
import {
|
||||
BsFileEarmarkText,
|
||||
BsPencil,
|
||||
BsStar,
|
||||
BsBook,
|
||||
BsHeadphones,
|
||||
BsPen,
|
||||
BsMegaphone,
|
||||
} from "react-icons/bs";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { useEffect, useState } from "react";
|
||||
import useStats from "@/hooks/useStats";
|
||||
import {averageScore, groupBySession, totalExams} from "@/utils/stats";
|
||||
import { averageScore, groupBySession, totalExams } from "@/utils/stats";
|
||||
import useUser from "@/hooks/useUser";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import Diagnostic from "@/components/Diagnostic";
|
||||
import {ToastContainer} from "react-toastify";
|
||||
import {capitalize} from "lodash";
|
||||
import {Module} from "@/interfaces";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { capitalize } from "lodash";
|
||||
import { Module } from "@/interfaces";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import Layout from "@/components/High/Layout";
|
||||
import {calculateAverageLevel} from "@/utils/score";
|
||||
import { calculateAverageLevel } from "@/utils/score";
|
||||
import axios from "axios";
|
||||
import DemographicInformationInput from "@/components/DemographicInformationInput";
|
||||
import moment from "moment";
|
||||
import Link from "next/link";
|
||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
||||
import ProfileSummary from "@/components/ProfileSummary";
|
||||
import StudentDashboard from "@/dashboards/Student";
|
||||
import AdminDashboard from "@/dashboards/Admin";
|
||||
import CorporateDashboard from "@/dashboards/Corporate";
|
||||
import TeacherDashboard from "@/dashboards/Teacher";
|
||||
import AgentDashboard from "@/dashboards/Agent";
|
||||
import MasterCorporateDashboard from "@/dashboards/MasterCorporate";
|
||||
import PaymentDue from "./(status)/PaymentDue";
|
||||
import {useRouter} from "next/router";
|
||||
import {PayPalScriptProvider} from "@paypal/react-paypal-js";
|
||||
import {CorporateUser, Type, userTypes} from "@/interfaces/user";
|
||||
import { useRouter } from "next/router";
|
||||
import { PayPalScriptProvider } from "@paypal/react-paypal-js";
|
||||
import {
|
||||
CorporateUser,
|
||||
MasterCorporateUser,
|
||||
Type,
|
||||
userTypes,
|
||||
} from "@/interfaces/user";
|
||||
import Select from "react-select";
|
||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||
const user = req.session.user;
|
||||
export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
|
||||
const user = req.session.user;
|
||||
|
||||
const envVariables: {[key: string]: string} = {};
|
||||
Object.keys(process.env)
|
||||
.filter((x) => x.startsWith("NEXT_PUBLIC"))
|
||||
.forEach((x: string) => {
|
||||
envVariables[x] = process.env[x]!;
|
||||
});
|
||||
const envVariables: { [key: string]: string } = {};
|
||||
Object.keys(process.env)
|
||||
.filter((x) => x.startsWith("NEXT_PUBLIC"))
|
||||
.forEach((x: string) => {
|
||||
envVariables[x] = process.env[x]!;
|
||||
});
|
||||
|
||||
if (!user || !user.isVerified) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/login",
|
||||
permanent: false,
|
||||
}
|
||||
};
|
||||
}
|
||||
if (!user || !user.isVerified) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/login",
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {user: req.session.user, envVariables},
|
||||
};
|
||||
return {
|
||||
props: { user: req.session.user, envVariables },
|
||||
};
|
||||
}, sessionOptions);
|
||||
|
||||
interface Props {
|
||||
user: any;
|
||||
envVariables: {[key: string]: string};
|
||||
user: any;
|
||||
envVariables: { [key: string]: string };
|
||||
}
|
||||
export default function Home(props: Props) {
|
||||
const { envVariables} = props;
|
||||
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
||||
const [showDemographicInput, setShowDemographicInput] = useState(false);
|
||||
const [selectedScreen, setSelectedScreen] = useState<Type>("admin");
|
||||
const { envVariables } = props;
|
||||
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
||||
const [showDemographicInput, setShowDemographicInput] = useState(false);
|
||||
const [selectedScreen, setSelectedScreen] = useState<Type>("admin");
|
||||
|
||||
const {user, mutateUser} = useUser({redirectTo: "/login"});
|
||||
const router = useRouter();
|
||||
const { user, mutateUser } = useUser({ redirectTo: "/login" });
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setShowDemographicInput(
|
||||
!user.demographicInformation ||
|
||||
!user.demographicInformation.country ||
|
||||
!user.demographicInformation.gender ||
|
||||
!user.demographicInformation.phone,
|
||||
);
|
||||
setShowDiagnostics(user.isFirstLogin && user.type === "student");
|
||||
}
|
||||
}, [user]);
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setShowDemographicInput(
|
||||
!user.demographicInformation ||
|
||||
!user.demographicInformation.country ||
|
||||
!user.demographicInformation.gender ||
|
||||
!user.demographicInformation.phone
|
||||
);
|
||||
setShowDiagnostics(user.isFirstLogin && user.type === "student");
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const checkIfUserExpired = () => {
|
||||
const expirationDate = user!.subscriptionExpirationDate;
|
||||
const checkIfUserExpired = () => {
|
||||
const expirationDate = user!.subscriptionExpirationDate;
|
||||
|
||||
if (expirationDate === null || expirationDate === undefined) return false;
|
||||
if (moment(expirationDate).isAfter(moment(new Date()))) return false;
|
||||
if (expirationDate === null || expirationDate === undefined) return false;
|
||||
if (moment(expirationDate).isAfter(moment(new Date()))) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
return true;
|
||||
};
|
||||
|
||||
if (user && (user.status === "paymentDue" || user.status === "disabled" || checkIfUserExpired())) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>EnCoach</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
{user.status === "disabled" && (
|
||||
<Layout user={user} navDisabled>
|
||||
<div className="flex flex-col items-center justify-center text-center w-full gap-4">
|
||||
<span className="font-bold text-lg">Your account has been disabled!</span>
|
||||
<span>Please contact an administrator if you believe this to be a mistake.</span>
|
||||
</div>
|
||||
</Layout>
|
||||
)}
|
||||
{(user.status === "paymentDue" || checkIfUserExpired()) && (
|
||||
<PaymentDue
|
||||
key={envVariables["NEXT_PUBLIC_PAYPAL_CLIENT_ID"]}
|
||||
hasExpired
|
||||
user={user}
|
||||
reload={router.reload}
|
||||
clientID={envVariables["NEXT_PUBLIC_PAYPAL_CLIENT_ID"] || ""}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (
|
||||
user &&
|
||||
(user.status === "paymentDue" ||
|
||||
user.status === "disabled" ||
|
||||
checkIfUserExpired())
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>EnCoach</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
{user.status === "disabled" && (
|
||||
<Layout user={user} navDisabled>
|
||||
<div className="flex flex-col items-center justify-center text-center w-full gap-4">
|
||||
<span className="font-bold text-lg">
|
||||
Your account has been disabled!
|
||||
</span>
|
||||
<span>
|
||||
Please contact an administrator if you believe this to be a
|
||||
mistake.
|
||||
</span>
|
||||
</div>
|
||||
</Layout>
|
||||
)}
|
||||
{(user.status === "paymentDue" || checkIfUserExpired()) && (
|
||||
<PaymentDue hasExpired user={user} reload={router.reload} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (user && showDemographicInput) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>EnCoach</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<Layout user={user} navDisabled>
|
||||
<DemographicInformationInput mutateUser={mutateUser} user={user} />
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (user && showDemographicInput) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>EnCoach</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<Layout user={user} navDisabled>
|
||||
<DemographicInformationInput mutateUser={mutateUser} user={user} />
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (user && showDiagnostics) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>EnCoach</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<Layout user={user} navDisabled>
|
||||
<Diagnostic user={user} onFinish={() => setShowDiagnostics(false)} />
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (user && showDiagnostics) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>EnCoach</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<Layout user={user} navDisabled>
|
||||
<Diagnostic user={user} onFinish={() => setShowDiagnostics(false)} />
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>EnCoach</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<ToastContainer />
|
||||
{user && (
|
||||
<Layout user={user}>
|
||||
{user.type === "student" && <StudentDashboard user={user} />}
|
||||
{user.type === "teacher" && <TeacherDashboard user={user} />}
|
||||
{user.type === "corporate" && <CorporateDashboard user={user} />}
|
||||
{user.type === "agent" && <AgentDashboard user={user} />}
|
||||
{user.type === "admin" && <AdminDashboard user={user} />}
|
||||
{user.type === "developer" && (
|
||||
<>
|
||||
<Select
|
||||
options={userTypes.map((u) => ({value: u, label: USER_TYPE_LABELS[u]}))}
|
||||
value={{value: selectedScreen, label: USER_TYPE_LABELS[selectedScreen]}}
|
||||
onChange={(value) => (value ? setSelectedScreen(value.value) : setSelectedScreen("admin"))}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>EnCoach</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<ToastContainer />
|
||||
{user && (
|
||||
<Layout user={user}>
|
||||
{checkAccess(user, ["student"]) && <StudentDashboard user={user} />}
|
||||
{checkAccess(user, ["teacher"]) && <TeacherDashboard user={user} />}
|
||||
{checkAccess(user, ["corporate"]) && (
|
||||
<CorporateDashboard user={user as CorporateUser} />
|
||||
)}
|
||||
{checkAccess(user, ["mastercorporate"]) && (
|
||||
<MasterCorporateDashboard user={user as MasterCorporateUser} />
|
||||
)}
|
||||
{checkAccess(user, ["agent"]) && <AgentDashboard user={user} />}
|
||||
{checkAccess(user, ["admin"]) && <AdminDashboard user={user} />}
|
||||
{checkAccess(user, ["developer"]) && (
|
||||
<>
|
||||
<Select
|
||||
options={userTypes.map((u) => ({
|
||||
value: u,
|
||||
label: USER_TYPE_LABELS[u],
|
||||
}))}
|
||||
value={{
|
||||
value: selectedScreen,
|
||||
label: USER_TYPE_LABELS[selectedScreen],
|
||||
}}
|
||||
onChange={(value) =>
|
||||
value
|
||||
? setSelectedScreen(value.value)
|
||||
: setSelectedScreen("admin")
|
||||
}
|
||||
/>
|
||||
|
||||
{selectedScreen === "student" && <StudentDashboard user={user} />}
|
||||
{selectedScreen === "teacher" && <TeacherDashboard user={user} />}
|
||||
{selectedScreen === "corporate" && <CorporateDashboard user={user as unknown as CorporateUser} />}
|
||||
{selectedScreen === "agent" && <AgentDashboard user={user} />}
|
||||
{selectedScreen === "admin" && <AdminDashboard user={user} />}
|
||||
</>
|
||||
)}
|
||||
</Layout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
{selectedScreen === "student" && <StudentDashboard user={user} />}
|
||||
{selectedScreen === "teacher" && <TeacherDashboard user={user} />}
|
||||
{selectedScreen === "corporate" && (
|
||||
<CorporateDashboard user={user as unknown as CorporateUser} />
|
||||
)}
|
||||
{selectedScreen === "mastercorporate" && (
|
||||
<MasterCorporateDashboard
|
||||
user={user as unknown as MasterCorporateUser}
|
||||
/>
|
||||
)}
|
||||
{selectedScreen === "agent" && <AgentDashboard user={user} />}
|
||||
{selectedScreen === "admin" && <AdminDashboard user={user} />}
|
||||
</>
|
||||
)}
|
||||
</Layout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,77 +1,85 @@
|
||||
import Layout from "@/components/High/Layout";
|
||||
import useUser from "@/hooks/useUser";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import useFilterStore from "@/stores/listFilterStore";
|
||||
import {withIronSessionSsr} from "iron-session/next";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import Head from "next/head";
|
||||
import {useRouter} from "next/router";
|
||||
import {useEffect} from "react";
|
||||
import {BsArrowLeft} from "react-icons/bs";
|
||||
import {ToastContainer} from "react-toastify";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import { BsArrowLeft } from "react-icons/bs";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import UserList from "../(admin)/Lists/UserList";
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||
const user = req.session.user;
|
||||
export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
|
||||
const user = req.session.user;
|
||||
|
||||
const envVariables: {[key: string]: string} = {};
|
||||
Object.keys(process.env)
|
||||
.filter((x) => x.startsWith("NEXT_PUBLIC"))
|
||||
.forEach((x: string) => {
|
||||
envVariables[x] = process.env[x]!;
|
||||
});
|
||||
const envVariables: { [key: string]: string } = {};
|
||||
Object.keys(process.env)
|
||||
.filter((x) => x.startsWith("NEXT_PUBLIC"))
|
||||
.forEach((x: string) => {
|
||||
envVariables[x] = process.env[x]!;
|
||||
});
|
||||
|
||||
if (!user || !user.isVerified) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/login",
|
||||
permanent: false,
|
||||
}
|
||||
};
|
||||
}
|
||||
if (!user || !user.isVerified) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/login",
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {user: req.session.user, envVariables},
|
||||
};
|
||||
return {
|
||||
props: { user: req.session.user, envVariables },
|
||||
};
|
||||
}, sessionOptions);
|
||||
|
||||
export default function UsersListPage() {
|
||||
const {user} = useUser();
|
||||
const {users} = useUsers();
|
||||
const [filters, clearFilters] = useFilterStore((state) => [state.userFilters, state.clearUserFilters]);
|
||||
const router = useRouter();
|
||||
const { user } = useUser();
|
||||
const { users } = useUsers();
|
||||
const [filters, clearFilters] = useFilterStore((state) => [
|
||||
state.userFilters,
|
||||
state.clearUserFilters,
|
||||
]);
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>EnCoach</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<ToastContainer />
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>EnCoach</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<ToastContainer />
|
||||
|
||||
{user && (
|
||||
<Layout user={user}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => {
|
||||
clearFilters();
|
||||
router.back();
|
||||
}}
|
||||
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">Users ({filters.map((f) => f.filter).reduce((d, f) => d.filter(f), users).length})</h2>
|
||||
</div>
|
||||
|
||||
<UserList user={user} filters={filters.map((f) => f.filter)} />
|
||||
</Layout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
{user && (
|
||||
<Layout user={user}>
|
||||
<UserList
|
||||
user={user}
|
||||
filters={filters.map((f) => f.filter)}
|
||||
renderHeader={(total) => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onClick={() => {
|
||||
clearFilters();
|
||||
router.back();
|
||||
}}
|
||||
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">Users ({total})</h2>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Layout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,62 +1,51 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Head from "next/head";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import {withIronSessionSsr} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import useUser from "@/hooks/useUser";
|
||||
import PaymentDue from "./(status)/PaymentDue";
|
||||
import { useRouter } from "next/router";
|
||||
import {useRouter} from "next/router";
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
|
||||
const user = req.session.user;
|
||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||
const user = req.session.user;
|
||||
|
||||
const envVariables: { [key: string]: string } = {};
|
||||
Object.keys(process.env)
|
||||
.filter((x) => x.startsWith("NEXT_PUBLIC"))
|
||||
.forEach((x: string) => {
|
||||
envVariables[x] = process.env[x]!;
|
||||
});
|
||||
const envVariables: {[key: string]: string} = {};
|
||||
Object.keys(process.env)
|
||||
.filter((x) => x.startsWith("NEXT_PUBLIC"))
|
||||
.forEach((x: string) => {
|
||||
envVariables[x] = process.env[x]!;
|
||||
});
|
||||
|
||||
if (!user || !user.isVerified) {
|
||||
return {
|
||||
if (!user || !user.isVerified) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/login",
|
||||
permanent: false,
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
props: { user: req.session.user, envVariables },
|
||||
};
|
||||
return {
|
||||
props: {user: req.session.user, envVariables},
|
||||
};
|
||||
}, sessionOptions);
|
||||
|
||||
export default function Home({
|
||||
envVariables,
|
||||
}: {
|
||||
envVariables: { [key: string]: string };
|
||||
}) {
|
||||
const { user } = useUser({ redirectTo: "/login" });
|
||||
const router = useRouter();
|
||||
export default function Home({envVariables}: {envVariables: {[key: string]: string}}) {
|
||||
const {user} = useUser({redirectTo: "/login"});
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>EnCoach</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
{user && (
|
||||
<PaymentDue
|
||||
key={envVariables["NEXT_PUBLIC_PAYPAL_CLIENT_ID"]}
|
||||
clientID={envVariables["NEXT_PUBLIC_PAYPAL_CLIENT_ID"] || ""}
|
||||
user={user}
|
||||
reload={router.reload}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>EnCoach</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
{user && <PaymentDue user={user} reload={router.reload} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
209
src/pages/permissions/[id].tsx
Normal file
209
src/pages/permissions/[id].tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Head from "next/head";
|
||||
import { useState } from "react";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
import { Permission, PermissionType } from "@/interfaces/permissions";
|
||||
import { getPermissionDoc } from "@/utils/permissions.be";
|
||||
import { User } from "@/interfaces/user";
|
||||
import Layout from "@/components/High/Layout";
|
||||
import { getUsers } from "@/utils/users.be";
|
||||
import { BsTrash } from "react-icons/bs";
|
||||
import Select from "@/components/Low/Select";
|
||||
import Button from "@/components/Low/Button";
|
||||
import axios from "axios";
|
||||
import { toast, ToastContainer } from "react-toastify";
|
||||
import {Type as UserType} from '@/interfaces/user'
|
||||
interface BasicUser {
|
||||
id: string;
|
||||
name: string;
|
||||
type: UserType
|
||||
}
|
||||
|
||||
interface PermissionWithBasicUsers {
|
||||
id: string;
|
||||
type: PermissionType;
|
||||
users: BasicUser[];
|
||||
}
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async (context) => {
|
||||
const { req, params } = context;
|
||||
const user = req.session.user;
|
||||
|
||||
if (!user || !user.isVerified) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/login",
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (shouldRedirectHome(user)) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/",
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!params?.id) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/permissions",
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch data from external API
|
||||
const permission: Permission = await getPermissionDoc(params.id as string);
|
||||
|
||||
const allUserData: User[] = await getUsers();
|
||||
|
||||
const users = allUserData.map((u) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
type: u.type
|
||||
})) as BasicUser[];
|
||||
|
||||
// const res = await fetch("api/permissions");
|
||||
// const permissions: Permission[] = await res.json();
|
||||
// Pass data to the page via props
|
||||
const usersData: BasicUser[] = permission.users.reduce(
|
||||
(acc: BasicUser[], userId) => {
|
||||
const user = users.find((u) => u.id === userId) as BasicUser;
|
||||
if (user) {
|
||||
acc.push(user);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
props: {
|
||||
// permissions: permissions.map((p) => ({ id: p.id, type: p.type })),
|
||||
permission: {
|
||||
...permission,
|
||||
id: params.id,
|
||||
users: usersData,
|
||||
},
|
||||
user: req.session.user,
|
||||
users,
|
||||
},
|
||||
};
|
||||
}, sessionOptions);
|
||||
|
||||
interface Props {
|
||||
permission: PermissionWithBasicUsers;
|
||||
user: User;
|
||||
users: BasicUser[];
|
||||
}
|
||||
|
||||
export default function Page(props: Props) {
|
||||
const { permission, user, users } = props;
|
||||
|
||||
|
||||
const [selectedUsers, setSelectedUsers] = useState<string[]>(() =>
|
||||
permission.users.map((u) => u.id)
|
||||
);
|
||||
|
||||
const onChange = (value: any) => {
|
||||
|
||||
setSelectedUsers((prev) => {
|
||||
if (value?.value) {
|
||||
return [...prev, value?.value];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
const removeUser = (id: string) => {
|
||||
setSelectedUsers((prev) => prev.filter((u) => u !== id));
|
||||
};
|
||||
|
||||
const update = async () => {
|
||||
|
||||
try {
|
||||
await axios.patch(`/api/permissions/${permission.id}`, {
|
||||
users: selectedUsers,
|
||||
});
|
||||
toast.success("Permission updated");
|
||||
} catch (err) {
|
||||
toast.error("Failed to update permission");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>EnCoach</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<ToastContainer />
|
||||
<Layout user={user} className="gap-6">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
Permission: {permission.type as string}
|
||||
</h1>
|
||||
<div className="flex gap-3">
|
||||
<Select
|
||||
value={null}
|
||||
options={users
|
||||
.filter((u) => !selectedUsers.includes(u.id))
|
||||
.map((u) => ({
|
||||
label: `${u?.type}-${u?.name}`,
|
||||
value: u.id,
|
||||
}))}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<Button onClick={update}>Update</Button>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2>Blacklisted Users</h2>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{selectedUsers.map((userId) => {
|
||||
const user = users.find((u) => u.id === userId);
|
||||
return (
|
||||
<div
|
||||
className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4"
|
||||
key={userId}
|
||||
>
|
||||
<span className="text-base first-letter:uppercase">{user?.type}-{user?.name}</span>
|
||||
<BsTrash
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => removeUser(userId)}
|
||||
size={20}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2>Whitelisted Users</h2>
|
||||
<div className="flex flex-col gap-3 flex-wrap">
|
||||
{users.filter(user => !selectedUsers.includes(user.id)).map((user) => {
|
||||
return (
|
||||
<div
|
||||
className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4"
|
||||
key={user.id}
|
||||
>
|
||||
<span className="text-base first-letter:uppercase">{user?.type}-{user?.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
80
src/pages/permissions/index.tsx
Normal file
80
src/pages/permissions/index.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Head from "next/head";
|
||||
import { withIronSessionSsr } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
import { Permission } from "@/interfaces/permissions";
|
||||
import { getPermissionDocs } from "@/utils/permissions.be";
|
||||
import { User } from "@/interfaces/user";
|
||||
import Layout from "@/components/High/Layout";
|
||||
import PermissionList from '@/components/PermissionList'
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(async ({ req }) => {
|
||||
const user = req.session.user;
|
||||
|
||||
if (!user || !user.isVerified) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/login",
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (shouldRedirectHome(user)) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/",
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch data from external API
|
||||
const permissions: Permission[] = await getPermissionDocs();
|
||||
|
||||
|
||||
// const res = await fetch("api/permissions");
|
||||
// const permissions: Permission[] = await res.json();
|
||||
// Pass data to the page via props
|
||||
return {
|
||||
props: {
|
||||
// permissions: permissions.map((p) => ({ id: p.id, type: p.type })),
|
||||
permissions: permissions.map((p) => {
|
||||
const { users, ...rest } = p;
|
||||
return rest;
|
||||
}),
|
||||
user: req.session.user,
|
||||
},
|
||||
};
|
||||
}, sessionOptions);
|
||||
|
||||
interface Props {
|
||||
permissions: Permission[];
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default function Page(props: Props) {
|
||||
|
||||
const { permissions, user } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>EnCoach</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<Layout user={user} className="gap-6">
|
||||
<h1 className="text-2xl font-semibold">Permissions</h1>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<PermissionList permissions={permissions} />
|
||||
</div>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
EMPLOYMENT_STATUS,
|
||||
Gender,
|
||||
User,
|
||||
DemographicInformation,
|
||||
} from "@/interfaces/user";
|
||||
import CountrySelect from "@/components/Low/CountrySelect";
|
||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||
@@ -47,6 +48,8 @@ import { InstructorGender } from "@/interfaces/exam";
|
||||
import { capitalize } from "lodash";
|
||||
import TopicModal from "@/components/Medium/TopicModal";
|
||||
import { v4 } from "uuid";
|
||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
|
||||
const user = req.session.user;
|
||||
|
||||
@@ -94,29 +97,28 @@ function UserProfile({ user, mutateUser }: Props) {
|
||||
const [desiredLevels, setDesiredLevels] = useState<
|
||||
{ [key in Module]: number } | undefined
|
||||
>(
|
||||
["developer", "student"].includes(user.type)
|
||||
? user.desiredLevels
|
||||
: undefined,
|
||||
checkAccess(user, ["developer", "student"]) ? user.desiredLevels : undefined
|
||||
);
|
||||
const [focus, setFocus] = useState<"academic" | "general">(user.focus);
|
||||
|
||||
const [country, setCountry] = useState<string>(
|
||||
user.demographicInformation?.country || "",
|
||||
user.demographicInformation?.country || ""
|
||||
);
|
||||
const [phone, setPhone] = useState<string>(
|
||||
user.demographicInformation?.phone || "",
|
||||
user.demographicInformation?.phone || ""
|
||||
);
|
||||
const [gender, setGender] = useState<Gender | undefined>(
|
||||
user.demographicInformation?.gender || undefined,
|
||||
user.demographicInformation?.gender || undefined
|
||||
);
|
||||
const [employment, setEmployment] = useState<EmploymentStatus | undefined>(
|
||||
user.type === "corporate"
|
||||
checkAccess(user, ["corporate", "mastercorporate"])
|
||||
? undefined
|
||||
: user.demographicInformation?.employment,
|
||||
: (user.demographicInformation as DemographicInformation)?.employment
|
||||
);
|
||||
const [passport_id, setPassportID] = useState<string | undefined>(
|
||||
user.type === "student"
|
||||
? user.demographicInformation?.passport_id
|
||||
: undefined,
|
||||
checkAccess(user, ["student"])
|
||||
? (user.demographicInformation as DemographicInformation)?.passport_id
|
||||
: undefined
|
||||
);
|
||||
|
||||
const [preferredGender, setPreferredGender] = useState<
|
||||
@@ -124,38 +126,38 @@ function UserProfile({ user, mutateUser }: Props) {
|
||||
>(
|
||||
user.type === "student" || user.type === "developer"
|
||||
? user.preferredGender || "varied"
|
||||
: undefined,
|
||||
: undefined
|
||||
);
|
||||
const [preferredTopics, setPreferredTopics] = useState<string[] | undefined>(
|
||||
user.type === "student" || user.type === "developer"
|
||||
? user.preferredTopics
|
||||
: undefined,
|
||||
: undefined
|
||||
);
|
||||
|
||||
const [position, setPosition] = useState<string | undefined>(
|
||||
user.type === "corporate"
|
||||
? user.demographicInformation?.position
|
||||
: undefined,
|
||||
: undefined
|
||||
);
|
||||
const [corporateInformation, setCorporateInformation] = useState(
|
||||
user.type === "corporate" ? user.corporateInformation : undefined,
|
||||
user.type === "corporate" ? user.corporateInformation : undefined
|
||||
);
|
||||
const [companyName, setCompanyName] = useState<string | undefined>(
|
||||
user.type === "agent" ? user.agentInformation?.companyName : undefined,
|
||||
user.type === "agent" ? user.agentInformation?.companyName : undefined
|
||||
);
|
||||
const [commercialRegistration, setCommercialRegistration] = useState<
|
||||
string | undefined
|
||||
>(
|
||||
user.type === "agent"
|
||||
? user.agentInformation?.commercialRegistration
|
||||
: undefined,
|
||||
: undefined
|
||||
);
|
||||
const [arabName, setArabName] = useState<string | undefined>(
|
||||
user.type === "agent" ? user.agentInformation?.companyArabName : undefined,
|
||||
user.type === "agent" ? user.agentInformation?.companyArabName : undefined
|
||||
);
|
||||
|
||||
const [timezone, setTimezone] = useState<string>(
|
||||
user.demographicInformation?.timezone || moment.tz.guess(),
|
||||
user.demographicInformation?.timezone || moment.tz.guess()
|
||||
);
|
||||
|
||||
const [isPreferredTopicsOpen, setIsPreferredTopicsOpen] = useState(false);
|
||||
@@ -194,7 +196,7 @@ function UserProfile({ user, mutateUser }: Props) {
|
||||
|
||||
if (newPassword && !password) {
|
||||
toast.error(
|
||||
"To update your password you need to input your current one!",
|
||||
"To update your password you need to input your current one!"
|
||||
);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
@@ -227,6 +229,7 @@ function UserProfile({ user, mutateUser }: Props) {
|
||||
desiredLevels,
|
||||
preferredGender,
|
||||
preferredTopics,
|
||||
focus,
|
||||
demographicInformation: {
|
||||
phone,
|
||||
country,
|
||||
@@ -277,7 +280,7 @@ function UserProfile({ user, mutateUser }: Props) {
|
||||
!user.subscriptionExpirationDate
|
||||
? "!bg-mti-green-ultralight !border-mti-green-light"
|
||||
: expirationDateColor(user.subscriptionExpirationDate),
|
||||
"bg-white border-mti-gray-platinum",
|
||||
"bg-white border-mti-gray-platinum"
|
||||
)}
|
||||
>
|
||||
{!user.subscriptionExpirationDate && "Unlimited"}
|
||||
@@ -297,7 +300,7 @@ function UserProfile({ user, mutateUser }: Props) {
|
||||
);
|
||||
|
||||
const manualDownloadLink = ["student", "teacher", "corporate"].includes(
|
||||
user.type,
|
||||
user.type
|
||||
)
|
||||
? `/manuals/${user.type}.pdf`
|
||||
: "";
|
||||
@@ -445,19 +448,52 @@ function UserProfile({ user, mutateUser }: Props) {
|
||||
|
||||
{desiredLevels &&
|
||||
["developer", "student"].includes(user.type) && (
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Desired Levels
|
||||
</label>
|
||||
<ModuleLevelSelector
|
||||
levels={desiredLevels}
|
||||
setLevels={
|
||||
setDesiredLevels as Dispatch<
|
||||
SetStateAction<{ [key in Module]: number }>
|
||||
<>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Desired Levels
|
||||
</label>
|
||||
<ModuleLevelSelector
|
||||
levels={desiredLevels}
|
||||
setLevels={
|
||||
setDesiredLevels as Dispatch<
|
||||
SetStateAction<{ [key in Module]: number }>
|
||||
>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Focus
|
||||
</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16 w-full">
|
||||
<button
|
||||
onClick={() => setFocus("academic")}
|
||||
className={clsx(
|
||||
"w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white",
|
||||
"hover:bg-mti-purple-light hover:text-white",
|
||||
focus === "academic" &&
|
||||
"!bg-mti-purple-light !text-white",
|
||||
"transition duration-300 ease-in-out"
|
||||
)}
|
||||
>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
Academic
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFocus("general")}
|
||||
className={clsx(
|
||||
"w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white",
|
||||
"hover:bg-mti-purple-light hover:text-white",
|
||||
focus === "general" &&
|
||||
"!bg-mti-purple-light !text-white",
|
||||
"transition duration-300 ease-in-out"
|
||||
)}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{preferredGender &&
|
||||
@@ -477,7 +513,7 @@ function UserProfile({ user, mutateUser }: Props) {
|
||||
onChange={(value) =>
|
||||
value
|
||||
? setPreferredGender(
|
||||
value.value as InstructorGender,
|
||||
value.value as InstructorGender
|
||||
)
|
||||
: null
|
||||
}
|
||||
@@ -586,7 +622,7 @@ function UserProfile({ user, mutateUser }: Props) {
|
||||
defaultValue={
|
||||
users.find(
|
||||
(x) =>
|
||||
x.id === user.corporateInformation.referralAgent,
|
||||
x.id === user.corporateInformation.referralAgent
|
||||
)?.name
|
||||
}
|
||||
type="text"
|
||||
@@ -601,7 +637,7 @@ function UserProfile({ user, mutateUser }: Props) {
|
||||
defaultValue={
|
||||
users.find(
|
||||
(x) =>
|
||||
x.id === user.corporateInformation.referralAgent,
|
||||
x.id === user.corporateInformation.referralAgent
|
||||
)?.email
|
||||
}
|
||||
type="text"
|
||||
@@ -620,8 +656,7 @@ function UserProfile({ user, mutateUser }: Props) {
|
||||
value={
|
||||
users.find(
|
||||
(x) =>
|
||||
x.id ===
|
||||
user.corporateInformation.referralAgent,
|
||||
x.id === user.corporateInformation.referralAgent
|
||||
)?.demographicInformation?.country
|
||||
}
|
||||
onChange={() => null}
|
||||
@@ -638,7 +673,7 @@ function UserProfile({ user, mutateUser }: Props) {
|
||||
defaultValue={
|
||||
users.find(
|
||||
(x) =>
|
||||
x.id === user.corporateInformation.referralAgent,
|
||||
x.id === user.corporateInformation.referralAgent
|
||||
)?.demographicInformation?.phone
|
||||
}
|
||||
disabled
|
||||
@@ -672,7 +707,7 @@ function UserProfile({ user, mutateUser }: Props) {
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute top-0 left-0 bg-mti-purple-light/60 w-full h-full z-20 flex items-center justify-center opacity-0 group-hover:opacity-100",
|
||||
"transition ease-in-out duration-300",
|
||||
"transition ease-in-out duration-300"
|
||||
)}
|
||||
>
|
||||
<BsCamera className="text-6xl text-mti-purple-ultralight/80" />
|
||||
|
||||
@@ -18,13 +18,15 @@ import {sortByModule} from "@/utils/moduleUtils";
|
||||
import Layout from "@/components/High/Layout";
|
||||
import clsx from "clsx";
|
||||
import {calculateBandScore} from "@/utils/score";
|
||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||
import {BsBook, BsClipboard, BsClock, BsHeadphones, BsMegaphone, BsPen, BsPersonDash, BsPersonFillX, BsXCircle} from "react-icons/bs";
|
||||
import Select from "@/components/Low/Select";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||
import useAssignments from "@/hooks/useAssignments";
|
||||
import {uuidv4} from "@firebase/util";
|
||||
import {usePDFDownload} from "@/hooks/usePDFDownload";
|
||||
import useRecordStore from "@/stores/recordStore";
|
||||
import ai_usage from "@/utils/ai.detection";
|
||||
|
||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||
const user = req.session.user;
|
||||
@@ -52,20 +54,30 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||
};
|
||||
}, sessionOptions);
|
||||
|
||||
const defaultSelectableCorporate = {
|
||||
value: "",
|
||||
label: "All",
|
||||
};
|
||||
|
||||
export default function History({user}: {user: User}) {
|
||||
const [statsUserId, setStatsUserId] = useState<string | undefined>(user.id);
|
||||
const [statsUserId, setStatsUserId] = useRecordStore((state) => [state.selectedUser, state.setSelectedUser]);
|
||||
// const [statsUserId, setStatsUserId] = useState<string | undefined>(user.id);
|
||||
const [groupedStats, setGroupedStats] = useState<{[key: string]: Stat[]}>();
|
||||
const [filter, setFilter] = useState<"months" | "weeks" | "days" | "assignments">();
|
||||
const {assignments} = useAssignments({});
|
||||
|
||||
const {users} = useUsers();
|
||||
const {stats, isLoading: isStatsLoading} = useStats(statsUserId);
|
||||
const {groups} = useGroups(user.id);
|
||||
const {groups: allGroups} = useGroups();
|
||||
|
||||
const groups = allGroups.filter((x) => x.admin === user.id);
|
||||
|
||||
const setExams = useExamStore((state) => state.setExams);
|
||||
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
const setInactivity = useExamStore((state) => state.setInactivity);
|
||||
const setTimeSpent = useExamStore((state) => state.setTimeSpent);
|
||||
const router = useRouter();
|
||||
const renderPdfIcon = usePDFDownload("stats");
|
||||
|
||||
@@ -87,6 +99,11 @@ export default function History({user}: {user: User}) {
|
||||
}
|
||||
}, [stats, isStatsLoading]);
|
||||
|
||||
// useEffect(() => {
|
||||
// // just set this initially
|
||||
// if (!statsUserId) setStatsUserId(user.id);
|
||||
// }, []);
|
||||
|
||||
const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => {
|
||||
setFilter((prev) => (prev === value ? undefined : value));
|
||||
};
|
||||
@@ -127,7 +144,9 @@ export default function History({user}: {user: User}) {
|
||||
};
|
||||
|
||||
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
||||
const scores: {[key in Module]: {total: number; missing: number; correct: number}} = {
|
||||
const scores: {
|
||||
[key in Module]: {total: number; missing: number; correct: number};
|
||||
} = {
|
||||
reading: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
@@ -179,12 +198,14 @@ export default function History({user}: {user: User}) {
|
||||
const assignment = assignments.find((a) => a.id === assignmentID);
|
||||
const isDisabled = dateStats.some((x) => x.isDisabled);
|
||||
|
||||
const aiUsage = Math.round(ai_usage(dateStats) * 100);
|
||||
|
||||
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||
module: x.module,
|
||||
level: calculateBandScore(x.correct, x.total, x.module, user.focus),
|
||||
}));
|
||||
|
||||
const {timeSpent, session} = dateStats[0];
|
||||
const {timeSpent, inactivity, session} = dateStats[0];
|
||||
|
||||
const selectExam = () => {
|
||||
const examPromises = uniqBy(dateStats, "exam").map((stat) => {
|
||||
@@ -192,8 +213,13 @@ export default function History({user}: {user: User}) {
|
||||
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(dateStats));
|
||||
setShowSolutions(true);
|
||||
setExams(exams.map((x) => x!).sort(sortByModule));
|
||||
@@ -217,21 +243,40 @@ export default function History({user}: {user: User}) {
|
||||
const content = (
|
||||
<>
|
||||
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
|
||||
<div className="flex md:flex-col 2xl:flex-row md:gap-1 -md:gap-2 2xl:gap-2 -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>
|
||||
{timeSpent && (
|
||||
<>
|
||||
<span className="md:hidden 2xl:flex">• </span>
|
||||
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</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-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 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>
|
||||
{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>
|
||||
</div>
|
||||
|
||||
@@ -272,13 +317,13 @@ export default function History({user}: {user: User}) {
|
||||
<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:hidden",
|
||||
"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",
|
||||
)}
|
||||
onClick={isDisabled ? () => null : selectExam}
|
||||
onClick={selectExam}
|
||||
data-tip="This exam is still being evaluated..."
|
||||
role="button">
|
||||
{content}
|
||||
@@ -299,6 +344,63 @@ export default function History({user}: {user: User}) {
|
||||
);
|
||||
};
|
||||
|
||||
const selectableCorporates = [
|
||||
defaultSelectableCorporate,
|
||||
...users
|
||||
.filter((x) => x.type === "corporate")
|
||||
.map((x) => ({
|
||||
value: x.id,
|
||||
label: `${x.name} - ${x.email}`,
|
||||
})),
|
||||
];
|
||||
|
||||
const [selectedCorporate, setSelectedCorporate] = useState<string>(defaultSelectableCorporate.value);
|
||||
|
||||
const getUsersList = (): User[] => {
|
||||
if (selectedCorporate) {
|
||||
// get groups for that corporate
|
||||
const selectedCorporateGroups = allGroups.filter((x) => x.admin === selectedCorporate);
|
||||
|
||||
// get the teacher ids for that group
|
||||
const selectedCorporateGroupsParticipants = selectedCorporateGroups.flatMap((x) => x.participants);
|
||||
|
||||
// // search for groups for these teachers
|
||||
// const teacherGroups = allGroups.filter((x) => {
|
||||
// return selectedCorporateGroupsParticipants.includes(x.admin);
|
||||
// });
|
||||
|
||||
// const usersList = [
|
||||
// ...selectedCorporateGroupsParticipants,
|
||||
// ...teacherGroups.flatMap((x) => x.participants),
|
||||
// ];
|
||||
const userListWithUsers = selectedCorporateGroupsParticipants.map((x) => users.find((y) => y.id === x)) as User[];
|
||||
return userListWithUsers.filter((x) => x);
|
||||
}
|
||||
|
||||
return users || [];
|
||||
};
|
||||
|
||||
const corporateFilteredUserList = getUsersList();
|
||||
|
||||
const getSelectedUser = () => {
|
||||
if (selectedCorporate) {
|
||||
const userInCorporate = corporateFilteredUserList.find((x) => x.id === statsUserId);
|
||||
return userInCorporate || corporateFilteredUserList[0];
|
||||
}
|
||||
|
||||
return users.find((x) => x.id === statsUserId) || user;
|
||||
};
|
||||
|
||||
const selectedUser = getSelectedUser();
|
||||
const selectedUserSelectValue = selectedUser
|
||||
? {
|
||||
value: selectedUser.id,
|
||||
label: `${selectedUser.name} - ${selectedUser.email}`,
|
||||
}
|
||||
: {
|
||||
value: "",
|
||||
label: "",
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
@@ -316,36 +418,64 @@ export default function History({user}: {user: User}) {
|
||||
<div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center">
|
||||
<div className="xl:w-3/4">
|
||||
{(user.type === "developer" || user.type === "admin") && (
|
||||
<Select
|
||||
options={users.map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))}
|
||||
defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}}
|
||||
onChange={(value) => setStatsUserId(value?.value)}
|
||||
styles={{
|
||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<label className="font-normal text-base text-mti-gray-dim">Corporate</label>
|
||||
|
||||
<Select
|
||||
options={selectableCorporates}
|
||||
value={selectableCorporates.find((x) => x.value === selectedCorporate)}
|
||||
onChange={(value) => setSelectedCorporate(value?.value || "")}
|
||||
styles={{
|
||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}></Select>
|
||||
<label className="font-normal text-base text-mti-gray-dim">User</label>
|
||||
|
||||
<Select
|
||||
options={corporateFilteredUserList.map((x) => ({
|
||||
value: x.id,
|
||||
label: `${x.name} - ${x.email}`,
|
||||
}))}
|
||||
value={selectedUserSelectValue}
|
||||
onChange={(value) => setStatsUserId(value?.value!)}
|
||||
styles={{
|
||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{(user.type === "corporate" || user.type === "teacher") && groups.length > 0 && (
|
||||
<Select
|
||||
options={users
|
||||
.filter((x) => groups.flatMap((y) => y.participants).includes(x.id))
|
||||
.map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))}
|
||||
defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}}
|
||||
onChange={(value) => setStatsUserId(value?.value)}
|
||||
styles={{
|
||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<label className="font-normal text-base text-mti-gray-dim">User</label>
|
||||
|
||||
<Select
|
||||
options={users
|
||||
.filter((x) => groups.flatMap((y) => y.participants).includes(x.id))
|
||||
.map((x) => ({
|
||||
value: x.id,
|
||||
label: `${x.name} - ${x.email}`,
|
||||
}))}
|
||||
value={selectedUserSelectValue}
|
||||
onChange={(value) => setStatsUserId(value?.value!)}
|
||||
styles={{
|
||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
||||
option: (styles, state) => ({
|
||||
...styles,
|
||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
||||
color: state.isFocused ? "black" : styles.color,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-4 w-full justify-center xl:justify-end">
|
||||
|
||||
@@ -26,7 +26,7 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||
};
|
||||
}
|
||||
|
||||
if (shouldRedirectHome(user) || !["developer", "admin", "corporate", "agent"].includes(user.type)) {
|
||||
if (shouldRedirectHome(user) || !["developer", "admin", "corporate", "agent", "mastercorporate"].includes(user.type)) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user