Merge branch 'develop' into ENCOA-83_MasterStatistical

This commit is contained in:
Joao Ramos
2024-08-19 23:38:46 +01:00
54 changed files with 4951 additions and 3823 deletions

17
components.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": false,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

23
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@beam-australia/react-env": "^3.1.1", "@beam-australia/react-env": "^3.1.1",
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@firebase/util": "^1.9.7", "@firebase/util": "^1.9.7",
"@headlessui/react": "^1.7.13", "@headlessui/react": "^1.7.13",
"@mdi/js": "^7.1.96", "@mdi/js": "^7.1.96",
@@ -453,6 +454,19 @@
"react-dom": ">=16.8.0" "react-dom": ">=16.8.0"
} }
}, },
"node_modules/@dnd-kit/sortable": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
"integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.1.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": { "node_modules/@dnd-kit/utilities": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
@@ -10430,6 +10444,15 @@
"tslib": "^2.0.0" "tslib": "^2.0.0"
} }
}, },
"@dnd-kit/sortable": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
"integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
"requires": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
}
},
"@dnd-kit/utilities": { "@dnd-kit/utilities": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",

View File

@@ -12,13 +12,15 @@
"dependencies": { "dependencies": {
"@beam-australia/react-env": "^3.1.1", "@beam-australia/react-env": "^3.1.1",
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@firebase/util": "^1.9.7", "@firebase/util": "^1.9.7",
"@headlessui/react": "^1.7.13", "@headlessui/react": "^2.1.2",
"@mdi/js": "^7.1.96", "@mdi/js": "^7.1.96",
"@mdi/react": "^1.6.1", "@mdi/react": "^1.6.1",
"@next/font": "13.1.6",
"@paypal/paypal-js": "^7.1.0", "@paypal/paypal-js": "^7.1.0",
"@paypal/react-paypal-js": "^8.1.3", "@paypal/react-paypal-js": "^8.1.3",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-popover": "^1.1.1",
"@react-pdf/renderer": "^3.1.14", "@react-pdf/renderer": "^3.1.14",
"@react-spring/web": "^9.7.4", "@react-spring/web": "^9.7.4",
"@tanstack/react-table": "^8.10.1", "@tanstack/react-table": "^8.10.1",
@@ -29,7 +31,8 @@
"axios": "^1.3.5", "axios": "^1.3.5",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"chart.js": "^4.2.1", "chart.js": "^4.2.1",
"clsx": "^1.2.1", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"countries-list": "^3.0.1", "countries-list": "^3.0.1",
"country-codes-list": "^1.6.11", "country-codes-list": "^1.6.11",
"currency-symbol-map": "^5.1.0", "currency-symbol-map": "^5.1.0",
@@ -48,7 +51,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.29.4", "moment": "^2.29.4",
"moment-timezone": "^0.5.44", "moment-timezone": "^0.5.44",
"next": "13.1.6", "next": "^14.2.5",
"nodemailer": "^6.9.5", "nodemailer": "^6.9.5",
"nodemailer-express-handlebars": "^6.1.0", "nodemailer-express-handlebars": "^6.1.0",
"primeicons": "^6.0.1", "primeicons": "^6.0.1",
@@ -77,7 +80,9 @@
"short-unique-id": "5.0.2", "short-unique-id": "5.0.2",
"stripe": "^13.10.0", "stripe": "^13.10.0",
"swr": "^2.1.3", "swr": "^2.1.3",
"tailwind-merge": "^2.5.2",
"tailwind-scrollbar-hide": "^1.1.7", "tailwind-scrollbar-hide": "^1.1.7",
"tailwindcss-animate": "^1.0.7",
"typescript": "4.9.5", "typescript": "4.9.5",
"use-file-picker": "^2.1.0", "use-file-picker": "^2.1.0",
"uuid": "^9.0.0", "uuid": "^9.0.0",

View File

@@ -1,187 +0,0 @@
import {FillBlanksExercise} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx";
import {Fragment, useEffect, useState} from "react";
import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import Button from "../Low/Button";
interface WordsDrawerProps {
words: {word: string; isDisabled: boolean}[];
isOpen: boolean;
blankId?: string;
previouslySelectedWord?: string;
onCancel: () => void;
onAnswer: (answer: string) => void;
}
function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel, onAnswer}: WordsDrawerProps) {
const [selectedWord, setSelectedWord] = useState<string | undefined>(previouslySelectedWord);
return (
<>
<div
className={clsx(
"w-full h-full absolute top-0 left-0 bg-gradient-to-t from-mti-black to-transparent z-10",
isOpen ? "visible opacity-10" : "invisible opacity-0",
)}
/>
<div
className={clsx(
"absolute w-full bg-white px-7 py-8 bottom-0 left-0 shadow-2xl rounded-2xl z-20 flex flex-col gap-8 transition-opacity duration-300 ease-in-out",
isOpen ? "visible opacity-100" : "invisible opacity-0",
)}>
<div className="w-full flex gap-2">
<div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-purple-light">{blankId}</div>
<span> Choose the correct word:</span>
</div>
<div className="grid grid-cols-6 gap-6" key="word-array">
{words.map(({word, isDisabled}) => (
<button
key={`${word}_${blankId}`}
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
className={clsx(
"rounded-full py-3 text-center transition duration-300 ease-in-out",
selectedWord === word ? "text-white bg-mti-purple-light" : "bg-mti-purple-ultralight",
!isDisabled && "hover:text-white hover:bg-mti-purple",
"disabled:cursor-not-allowed disabled:text-mti-gray-dim",
)}
disabled={isDisabled}>
{word}
</button>
))}
</div>
<div className="flex justify-between w-full">
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
Cancel
</Button>
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
Confirm
</Button>
</div>
</div>
</>
);
}
export default function FillBlanks({
id,
allowRepetition,
type,
prompt,
solutions,
text,
words,
userSolutions,
onNext,
onBack,
}: FillBlanksExercise & CommonProps) {
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = answers.filter((x) => {
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution.toLowerCase();
if (!solution) return false;
const option = words.find((w) =>
typeof w === "string" ? w.toLowerCase() === x.solution.toLowerCase() : w.letter.toLowerCase() === x.solution.toLowerCase(),
);
if (!option) return false;
return solution === (typeof option === "string" ? option.toLowerCase() : option.word.toLowerCase());
}).length;
const missing = total - answers.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return {total, correct, missing};
};
const renderLines = (line: string) => {
return (
<div className="text-base leading-5">
{reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = answers.find((x) => x.id === id);
return (
<input
className={clsx(
"rounded-full hover:text-white focus:ring-0 focus:outline-none focus:!text-white focus:bg-mti-purple transition duration-300 ease-in-out my-1 px-5 py-2 text-center",
!userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
userSolution && "px-5 py-2 text-center text-mti-purple-dark bg-mti-purple-ultralight",
)}
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), {id, solution: e.target.value}])}
value={userSolution?.solution}></input>
);
})}
</div>
);
};
return (
<>
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</span>
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{text.split("\\n").map((line, index) => (
<p key={index}>
{renderLines(line)}
<br />
</p>
))}
</span>
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-4">
<span className="font-medium text-mti-purple-dark">Options</span>
<div className="flex gap-4 flex-wrap">
{words.map((v) => {
const text = typeof v === "string" ? v : `${v.letter} - ${v.word}`;
return (
<span
className={clsx(
"border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300",
!!answers.find((x) => x.solution.toLowerCase() === (typeof v === "string" ? v : v.letter).toLowerCase()) &&
"bg-mti-purple-dark text-white",
)}
key={text}>
{text}
</span>
);
})}
</div>
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={() => onNext({exercise: id, solutions: answers, score: calculateScore(), type})}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</>
);
}

View File

@@ -0,0 +1,64 @@
import Button from "@/components/Low/Button";
import clsx from "clsx";
import { useState } from "react";
interface WordsDrawerProps {
words: {word: string; isDisabled: boolean}[];
isOpen: boolean;
blankId?: string;
previouslySelectedWord?: string;
onCancel: () => void;
onAnswer: (answer: string) => void;
}
const WordsDrawer: React.FC<WordsDrawerProps> = ({words, isOpen, blankId, previouslySelectedWord, onCancel, onAnswer}) => {
const [selectedWord, setSelectedWord] = useState<string | undefined>(previouslySelectedWord);
return (
<>
<div
className={clsx(
"w-full h-full absolute top-0 left-0 bg-gradient-to-t from-mti-black to-transparent z-10",
isOpen ? "visible opacity-10" : "invisible opacity-0",
)}
/>
<div
className={clsx(
"absolute w-full bg-white px-7 py-8 bottom-0 left-0 shadow-2xl rounded-2xl z-20 flex flex-col gap-8 transition-opacity duration-300 ease-in-out",
isOpen ? "visible opacity-100" : "invisible opacity-0",
)}>
<div className="w-full flex gap-2">
<div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-purple-light">{blankId}</div>
<span> Choose the correct word:</span>
</div>
<div className="grid grid-cols-6 gap-6" key="word-array">
{words.map(({word, isDisabled}) => (
<button
key={`${word}_${blankId}`}
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
className={clsx(
"rounded-full py-3 text-center transition duration-300 ease-in-out",
selectedWord === word ? "text-white bg-mti-purple-light" : "bg-mti-purple-ultralight",
!isDisabled && "hover:text-white hover:bg-mti-purple",
"disabled:cursor-not-allowed disabled:text-mti-gray-dim",
)}
disabled={isDisabled}>
{word}
</button>
))}
</div>
<div className="flex justify-between w-full">
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
Cancel
</Button>
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
Confirm
</Button>
</div>
</div>
</>
);
}
export default WordsDrawer;

View File

@@ -0,0 +1,232 @@
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx";
import { Fragment, useEffect, useState } from "react";
import reactStringReplace from "react-string-replace";
import { CommonProps } from "..";
import Button from "../../Low/Button";
import { v4 } from "uuid";
const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
id,
type,
prompt,
solutions,
text,
words,
userSolutions,
variant,
onNext,
onBack,
}) => {
//const { shuffleMaps } = useExamStore((state) => state);
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const [currentMCSelection, setCurrentMCSelection] = useState<{ id: string, selection: FillBlanksMCOption }>();
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
return Array.isArray(words) && words.every(
word => word && typeof word === 'object' && 'id' in word && 'options' in word
);
}
const excludeWordMCType = (x: any) => {
return typeof x === "string" ? x : x as { letter: string; word: string };
}
useEffect(() => {
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = userSolutions.filter((x) => {
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
if (!solution) return false;
const option = words.find((w) => {
if (typeof w === "string") {
return w.toLowerCase() === x.solution.toLowerCase();
} else if ('letter' in w) {
return w.word.toLowerCase() === x.solution.toLowerCase();
} else {
return w.id === x.id;
}
});
if (!option) return false;
if (typeof option === "string") {
return solution.toLowerCase() === option.toLowerCase();
} else if ('letter' in option) {
return solution.toLowerCase() === option.word.toLowerCase();
} else if ('options' in option) {
/*
if (shuffleMaps.length !== 0) {
const shuffleMap = shuffleMaps.find((map) => map.id == x.id)
if (!shuffleMap) {
return false;
}
const original = shuffleMap[x.solution as keyof typeof shuffleMap];
return solution.toLowerCase() === (option.options[original as keyof typeof option.options] || '').toLowerCase();
}*/
return solution.toLowerCase() === (option.options[x.solution as keyof typeof option.options] || '').toLowerCase();
}
return false;
}).length;
const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return { total, correct, missing };
};
const renderLines = (line: string) => {
return (
<div className="text-base leading-5">
{reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = answers.find((x) => x.id === id);
const styles = clsx(
"rounded-full hover:text-white focus:ring-0 focus:outline-none focus:!text-white focus:bg-mti-purple transition duration-300 ease-in-out my-1 px-5 py-2 text-center",
!userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight",
)
return (
variant === "mc" ? (
<button
className={styles}
onClick={() => {
setCurrentMCSelection(
{
id: id,
selection: words.find((x) => {
if (typeof x !== "string" && 'id' in x) {
return (x as FillBlanksMCOption).id.toString() == id.toString();
}
return false;
}) as FillBlanksMCOption
}
);
}}
>
{userSolution?.solution === undefined ? <span className="text-transparent select-none">placeholder</span> : <span> {userSolution.solution} </span>}
</button>
) : (
<input
className={styles}
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])}
value={userSolution?.solution} />
)
);
})}
</div>
);
};
const onSelection = (id: string, value: string) => {
setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id: id, solution: value }]);
}
/*const getShuffles = () => {
let shuffle = {};
if (shuffleMaps.length !== 0) {
shuffle = {
shuffleMaps: shuffleMaps.filter((map) =>
answers.some(answer => answer.id === map.id)
)
}
}
return shuffle;
}*/
return (
<>
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
<span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</span>
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{text.split("\\n").map((line, index) => (
<p key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}>
{renderLines(line)}
<br />
</p>
))}
</span>
{variant === "mc" && typeCheckWordsMC(words) ? (
<>
{currentMCSelection && (
<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">
{currentMCSelection.selection?.options && Object.entries(currentMCSelection.selection.options).sort((a, b) => a[0].localeCompare(b[0])).map(([key, value]) => {
return <button
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.toLocaleLowerCase() === value.toLocaleLowerCase() && x.id === currentMCSelection.id ) &&
"bg-mti-purple-dark text-white",
)}
key={v4()}
onClick={() => onSelection(currentMCSelection.id, value)}
>
{value}
</button>;
})}
</div>
</div>
)}
</>
) : (
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-4">
<span className="font-medium text-mti-purple-dark">Options</span>
<div className="flex gap-4 flex-wrap">
{words.map((v) => {
v = excludeWordMCType(v);
const text = typeof v === "string" ? v : `${v.letter} - ${v.word}`;
return (
<span
className={clsx(
"border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300",
!!answers.find((x) => x.solution.toLowerCase() === (typeof v === "string" ? v : ("letter" in v ? v.letter : "")).toLowerCase()) &&
"bg-mti-purple-dark text-white",
)}
key={v4()}
>
{text}
</span>
)
})}
</div>
</div >
)}
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, })}//...getShuffles() })}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={() => onNext({ exercise: id, solutions: answers, score: calculateScore(), type, })}//...getShuffles() })}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</>
);
}
export default FillBlanks;

View File

@@ -1,10 +1,10 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam"; import { MultipleChoiceExercise, MultipleChoiceQuestion } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import clsx from "clsx"; import clsx from "clsx";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import {CommonProps} from "."; import { CommonProps } from ".";
import Button from "../Low/Button"; import Button from "../Low/Button";
function Question({ function Question({
@@ -14,22 +14,30 @@ function Question({
options, options,
userSolution, userSolution,
onSelectOption, onSelectOption,
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) { }: MultipleChoiceQuestion & {
userSolution: string | undefined;
onSelectOption?: (option: string) => void;
showSolution?: boolean,
}) {
/*
const renderPrompt = (prompt: string) => { const renderPrompt = (prompt: string) => {
return reactStringReplace(prompt, /((<u>)[\w\s']+(<\/u>))/g, (match) => { return reactStringReplace(prompt, /((<u>)[\w\s']+(<\/u>))/g, (match) => {
const word = match.replaceAll("<u>", "").replaceAll("</u>", ""); const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
return word.length > 0 ? <u>{word}</u> : null; return word.length > 0 ? <u>{word}</u> : null;
}); });
}; };
*/
return ( return (
// {renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}
<div className="flex flex-col gap-10"> <div className="flex flex-col gap-10">
{isNaN(Number(id)) ? ( {isNaN(Number(id)) ? (
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span> <span dangerouslySetInnerHTML={{ __html: prompt }} />
) : ( ) : (
<span className=""> <span className="">
<> <>
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span> {id} - <span dangerouslySetInnerHTML={{ __html: prompt }} />
</> </>
</span> </span>
)} )}
@@ -65,53 +73,79 @@ function Question({
); );
} }
export default function MultipleChoice({id, prompt, type, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) { export default function MultipleChoice({ id, prompt, type, questions, userSolutions, onNext, onBack }: MultipleChoiceExercise & CommonProps) {
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions); const [answers, setAnswers] = useState<{ question: string; option: string }[]>(userSolutions);
const {questionIndex, setQuestionIndex} = useExamStore((state) => state); //const { shuffleMaps } = useExamStore((state) => state);
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state); const { questionIndex, setQuestionIndex } = useExamStore((state) => state);
const { userSolutions: storeUserSolutions, setUserSolutions } = useExamStore((state) => state);
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
useEffect(() => { useEffect(() => {
setUserSolutions([...storeUserSolutions.filter((x) => x.exercise !== id), {exercise: id, solutions: answers, score: calculateScore(), type}]); setUserSolutions(
[...storeUserSolutions.filter((x) => x.exercise !== id), {
exercise: id, solutions: answers, score: calculateScore(), type
}]);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers]); }, [answers]);
useEffect(() => { useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type}); if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]); }, [hasExamEnded]);
const onSelectOption = (option: string) => { const onSelectOption = (option: string) => {
const question = questions[questionIndex]; const question = questions[questionIndex];
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]); setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), { option, question: question.id }]);
}; };
const calculateScore = () => { const calculateScore = () => {
const total = questions.length; const total = questions.length;
const correct = answers.filter( const correct = answers.filter((x) => {
(x) => questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false, const matchingQuestion = questions.find((y) => {
).length; return y.id.toString() === x.question.toString();
const missing = total - answers.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length; });
return {total, correct, missing}; let isSolutionCorrect;
//if (shuffleMaps.length == 0) {
isSolutionCorrect = matchingQuestion?.solution === x.option;
//} else {
// const shuffleMap = shuffleMaps.find((map) => map.id == x.question)
// isSolutionCorrect = shuffleMap?.map[x.option] == matchingQuestion?.solution;
//}
return isSolutionCorrect || false;
}).length;
const missing = total - correct;
return { total, correct, missing };
}; };
/*const getShuffles = () => {
let shuffle = {};
if (shuffleMaps.length !== 0) {
shuffle = {
shuffleMaps: shuffleMaps.filter((map) =>
answers.some(answer => answer.question === map.id)
)
}
}
return shuffle;
}*/
const next = () => { const next = () => {
if (questionIndex === questions.length - 1) { if (questionIndex === questions.length - 1) {
onNext({exercise: id, solutions: answers, score: calculateScore(), type}); onNext({ exercise: id, solutions: answers, score: calculateScore(), type, });//...getShuffles() });
} else { } else {
setQuestionIndex(questionIndex + 1); setQuestionIndex(questionIndex + 1);
} }
scrollToTop(); scrollToTop();
}; };
const back = () => { const back = () => {
if (questionIndex === 0) { if (questionIndex === 0) {
onBack({exercise: id, solutions: answers, score: calculateScore(), type}); onBack({ exercise: id, solutions: answers, score: calculateScore(), type, });// ...getShuffles() });
} else { } else {
setQuestionIndex(questionIndex - 1); setQuestionIndex(questionIndex - 1);
} }

View File

@@ -63,7 +63,7 @@ const FillBlanksEdit = (props: Props) => {
label={`Word ${index + 1}`} label={`Word ${index + 1}`}
name="word" name="word"
required required
value={typeof word === "string" ? word : word.word} value={typeof word === "string" ? word : ("word" in word ? word.word : "")}
onChange={(value) => onChange={(value) =>
updateExercise({ updateExercise({
words: exercise.words.map((sol, idx) => words: exercise.words.map((sol, idx) =>

View File

@@ -0,0 +1,39 @@
import { useCallback } from "react";
const HighlightContent: React.FC<{
html: string;
highlightPhrases: string[],
firstOccurence?: boolean
}> = ({
html,
highlightPhrases,
firstOccurence = false
}) => {
const createHighlightedContent = useCallback(() => {
if (highlightPhrases.length === 0) {
return { __html: html };
}
const escapeRegExp = (string: string) => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
const regex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'i');
const globalRegex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'gi');
let highlightedHtml = html;
if (firstOccurence) {
highlightedHtml = html.replace(regex, (match) => `<span style="background-color: yellow;">${match}</span>`);
} else {
highlightedHtml = html.replace(globalRegex, (match) => `<span style="background-color: yellow;">${match}</span>`);
}
return { __html: highlightedHtml };
}, [html, highlightPhrases, firstOccurence]);
return <div dangerouslySetInnerHTML={createHighlightedContent()} />;
};
export default HighlightContent;

51
src/components/List.tsx Normal file
View File

@@ -0,0 +1,51 @@
import {Column, flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table";
export default function List<T>({data, columns}: {data: T[]; columns: any[]}) {
const table = useReactTable({
data,
columns: columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder ? null : (
<>
<div
{...{
className: header.column.getCanSort() ? "cursor-pointer select-none py-4 text-left first:pl-4" : "",
onClick: header.column.getToggleSortingHandler(),
}}>
{flexRender(header.column.columnDef.header, header.getContext())}
{{
asc: " 🔼",
desc: " 🔽",
}[header.column.getIsSorted() as string] ?? null}
</div>
</>
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}

View File

@@ -13,6 +13,7 @@ import {
BsCurrencyDollar, BsCurrencyDollar,
BsClipboardData, BsClipboardData,
BsFileLock, BsFileLock,
BsPeople,
} from "react-icons/bs"; } from "react-icons/bs";
import {CiDumbbell} from "react-icons/ci"; import {CiDumbbell} from "react-icons/ci";
import {RiLogoutBoxFill} from "react-icons/ri"; import {RiLogoutBoxFill} from "react-icons/ri";
@@ -109,6 +110,9 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && ( {checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} /> <Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
)} )}
{checkAccess(user, ["developer", "admin", "teacher", "student"], permissions) && (
<Nav disabled={disableNavigation} Icon={BsPeople} label="Groups" path={path} keyPath="/groups" isMinimized={isMinimized} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && ( {checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} /> <Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
)} )}
@@ -146,25 +150,25 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
badge={totalAssignedTickets} badge={totalAssignedTickets}
/> />
)} )}
{checkAccess(user, ["developer", "admin"]) && ( {checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
<> <Nav
<Nav disabled={disableNavigation}
disabled={disableNavigation} Icon={BsCloudFill}
Icon={BsCloudFill} label="Generation"
label="Generation" path={path}
path={path} keyPath="/generation"
keyPath="/generation" isMinimized={isMinimized}
isMinimized={isMinimized} />
/> )}
<Nav {checkAccess(user, ["developer", "admin", "corporate", "mastercorporate", "agent"]) && (
disabled={disableNavigation} <Nav
Icon={BsFileLock} disabled={disableNavigation}
label="Permissions" Icon={BsFileLock}
path={path} label="Permissions"
keyPath="/permissions" path={path}
isMinimized={isMinimized} keyPath="/permissions"
/> isMinimized={isMinimized}
</> />
)} )}
</div> </div>
<div className="-xl:flex flex-col gap-3 xl:hidden"> <div className="-xl:flex flex-col gap-3 xl:hidden">

View File

@@ -1,8 +1,8 @@
import {FillBlanksExercise} from "@/interfaces/exam"; import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
import clsx from "clsx"; import clsx from "clsx";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import {CommonProps} from "."; import { CommonProps } from ".";
import {Fragment} from "react"; import { Fragment } from "react";
import Button from "../Low/Button"; import Button from "../Low/Button";
export default function FillBlanksSolutions({ export default function FillBlanksSolutions({
@@ -19,21 +19,42 @@ export default function FillBlanksSolutions({
const calculateScore = () => { const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0; const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = userSolutions.filter((x) => { const correct = userSolutions.filter((x) => {
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution.toLowerCase(); const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
if (!solution) return false; if (!solution) return false;
const option = words.find((w) => const option = words.find((w) => {
typeof w === "string" ? w.toLowerCase() === x.solution.toLowerCase() : w.letter.toLowerCase() === x.solution.toLowerCase(), if (typeof w === "string") {
); return w.toLowerCase() === x.solution.toLowerCase();
} else if ('letter' in w) {
return w.word.toLowerCase() === x.solution.toLowerCase();
} else {
return w.id === x.id;
}
});
if (!option) return false; if (!option) return false;
return solution === (typeof option === "string" ? option.toLowerCase() : option.word.toLowerCase()); if (typeof option === "string") {
return solution.toLowerCase() === option.toLowerCase();
} else if ('letter' in option) {
return solution.toLowerCase() === option.word.toLowerCase();
} else if ('options' in option) {
return option.options[solution as keyof typeof option.options] == x.solution;
}
return false;
}).length; }).length;
const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length; const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return {total, correct, missing}; return { total, correct, missing };
}; };
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
return Array.isArray(words) && words.every(
word => word && typeof word === 'object' && 'id' in word && 'options' in word
);
}
const renderLines = (line: string) => { const renderLines = (line: string) => {
return ( return (
<span> <span>
@@ -56,23 +77,53 @@ export default function FillBlanksSolutions({
const userSolutionWord = words.find((w) => const userSolutionWord = words.find((w) =>
typeof w === "string" typeof w === "string"
? w.toLowerCase() === userSolution.solution.toLowerCase() ? w.toLowerCase() === userSolution.solution.toLowerCase()
: w.letter.toLowerCase() === userSolution.solution.toLowerCase(), : 'letter' in w
? w.letter.toLowerCase() === userSolution.solution.toLowerCase()
: 'options' in w
? w.id === userSolution.id
: false
); );
const userSolutionText = typeof userSolutionWord === "string" ? userSolutionWord : userSolutionWord?.word;
if (userSolutionText === solution.solution) { const userSolutionText =
typeof userSolutionWord === "string"
? userSolutionWord
: userSolutionWord && 'letter' in userSolutionWord
? userSolutionWord.word
: userSolutionWord && 'options' in userSolutionWord
? userSolution.solution
: userSolution.solution;
let correct;
let solutionText;
if (typeCheckWordsMC(words)) {
const options = words.find((x) => x.id === id);
if (options) {
const correctKey = Object.keys(options.options).find(key =>
key.toLowerCase() === solution.solution.toLowerCase()
);
correct = userSolution.solution == options.options[correctKey as keyof typeof options.options];
solutionText = options.options[correctKey as keyof typeof options.options] || solution.solution;
} else {
correct = false;
solutionText = solution?.solution;
}
} else {
correct = userSolutionText === solution.solution;
solutionText = solution.solution;
}
if (correct) {
return ( return (
<button <button
className={clsx( className={clsx(
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1", "rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light", userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
)}> )}>
{solution.solution} {solutionText}
</button> </button>
); );
} } else {
if (userSolutionText !== solution.solution) {
return ( return (
<> <>
<button <button
@@ -88,7 +139,7 @@ export default function FillBlanksSolutions({
"rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1", "rounded-full hover:text-white hover:bg-mti-purple transition duration-300 ease-in-out my-1",
userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light", userSolution && "px-5 py-2 text-center text-white bg-mti-purple-light",
)}> )}>
{solution.solution} {solutionText}
</button> </button>
</> </>
); );
@@ -138,14 +189,14 @@ export default function FillBlanksSolutions({
<Button <Button
color="purple" color="purple"
variant="outline" variant="outline"
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})} onClick={() => onBack({ exercise: id, solutions: userSolutions, score: calculateScore(), type })}
className="max-w-[200px] w-full"> className="max-w-[200px] w-full">
Back Back
</Button> </Button>
<Button <Button
color="purple" color="purple"
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})} onClick={() => onNext({ exercise: id, solutions: userSolutions, score: calculateScore(), type })}
className="max-w-[200px] self-end w-full"> className="max-w-[200px] self-end w-full">
Next Next
</Button> </Button>

View File

@@ -1,10 +1,10 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam"; import { MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import clsx from "clsx"; import clsx from "clsx";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import {CommonProps} from "."; import { CommonProps } from ".";
import Button from "../Low/Button"; import Button from "../Low/Button";
function Question({ function Question({
@@ -14,7 +14,40 @@ function Question({
solution, solution,
options, options,
userSolution, userSolution,
}: MultipleChoiceQuestion & {userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean}) { }: MultipleChoiceQuestion & { userSolution: string | undefined; onSelectOption?: (option: string) => void; showSolution?: boolean }) {
const { userSolutions } = useExamStore((state) => state);
/*
const getShuffledOptions = (options: {id: string, text: string}[], questionShuffleMap: ShuffleMap) => {
const shuffledOptions = ['A', 'B', 'C', 'D'].map(newId => {
const originalId = questionShuffleMap.map[newId];
const originalOption = options.find(option => option.id === originalId);
return {
id: newId,
text: originalOption!.text
};
});
return shuffledOptions;
}
const getShuffledSolution = (originalSolution: string, questionShuffleMap: ShuffleMap) => {
for (const [newPosition, originalPosition] of Object.entries(questionShuffleMap.map)) {
if (originalPosition === originalSolution) {
return newPosition;
}
}
return originalSolution;
}
const questionShuffleMap = userSolutions.reduce((foundMap, userSolution) => {
if (foundMap) return foundMap;
return userSolution.shuffleMaps?.find(map => map.id === id) || null;
}, null as ShuffleMap | null);
*/
const questionOptions = options; // questionShuffleMap ? getShuffledOptions(options as {id: string, text: string}[], questionShuffleMap) : options;
const newSolution = solution; //questionShuffleMap ? getShuffledSolution(solution, questionShuffleMap) : solution;
const renderPrompt = (prompt: string) => { const renderPrompt = (prompt: string) => {
return reactStringReplace(prompt, /((<u>)[\w\s']+(<\/u>))/g, (match) => { return reactStringReplace(prompt, /((<u>)[\w\s']+(<\/u>))/g, (match) => {
const word = match.replaceAll("<u>", "").replaceAll("</u>", ""); const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
@@ -23,11 +56,11 @@ function Question({
}; };
const optionColor = (option: string) => { const optionColor = (option: string) => {
if (option === solution && !userSolution) { if (option === newSolution && !userSolution) {
return "!border-mti-gray-davy !text-mti-gray-davy"; return "!border-mti-gray-davy !text-mti-gray-davy";
} }
if (option === solution) { if (option === newSolution) {
return "!border-mti-purple-light !text-mti-purple-light"; return "!border-mti-purple-light !text-mti-purple-light";
} }
@@ -47,24 +80,24 @@ function Question({
)} )}
<div className="grid grid-cols-4 gap-4 place-items-center"> <div className="grid grid-cols-4 gap-4 place-items-center">
{variant === "image" && {variant === "image" &&
options.map((option) => ( questionOptions.map((option) => (
<div <div
key={option.id} key={option?.id}
className={clsx( className={clsx(
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative", "flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative",
optionColor(option.id), optionColor(option!.id),
)}> )}>
<span className={clsx("text-sm", solution !== option.id && userSolution !== option.id && "opacity-50")}>{option.id}</span> <span className={clsx("text-sm", newSolution !== option?.id && userSolution !== option?.id && "opacity-50")}>{option?.id}</span>
<img src={option.src!} alt={`Option ${option.id}`} /> {"src" in option && <img src={option?.src!} alt={`Option ${option?.id}`} />}
</div> </div>
))} ))}
{variant === "text" && {variant === "text" &&
options.map((option) => ( questionOptions.map((option) => (
<div <div
key={option.id} key={option?.id}
className={clsx("flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm", optionColor(option.id))}> className={clsx("flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm", optionColor(option!.id))}>
<span className="font-semibold">{option.id}.</span> <span className="font-semibold">{option?.id}.</span>
<span>{option.text}</span> <span>{option?.text}</span>
</div> </div>
))} ))}
</div> </div>
@@ -72,8 +105,8 @@ function Question({
); );
} }
export default function MultipleChoice({id, type, prompt, questions, userSolutions, onNext, onBack}: MultipleChoiceExercise & CommonProps) { export default function MultipleChoice({ id, type, prompt, questions, userSolutions, onNext, onBack }: MultipleChoiceExercise & CommonProps) {
const {questionIndex, setQuestionIndex} = useExamStore((state) => state); const { questionIndex, setQuestionIndex } = useExamStore((state) => state);
const calculateScore = () => { const calculateScore = () => {
const total = questions.length; const total = questions.length;
@@ -82,12 +115,12 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
).length; ).length;
const missing = total - userSolutions.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length; const missing = total - userSolutions.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length;
return {total, correct, missing}; return { total, correct, missing };
}; };
const next = () => { const next = () => {
if (questionIndex === questions.length - 1) { if (questionIndex === questions.length - 1) {
onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type}); onNext({ exercise: id, solutions: userSolutions, score: calculateScore(), type });
} else { } else {
setQuestionIndex(questionIndex + 1); setQuestionIndex(questionIndex + 1);
} }
@@ -95,7 +128,7 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
const back = () => { const back = () => {
if (questionIndex === 0) { if (questionIndex === 0) {
onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type}); onBack({ exercise: id, solutions: userSolutions, score: calculateScore(), type });
} else { } else {
setQuestionIndex(questionIndex - 1); setQuestionIndex(questionIndex - 1);
} }

View File

@@ -1,23 +0,0 @@
import { useCallback } from "react";
const HighlightedContent: React.FC<{ html: string; highlightPhrases: string[] }> = ({ html, highlightPhrases }) => {
const createHighlightedContent = useCallback(() => {
if (highlightPhrases.length === 0) {
return { __html: html };
}
const escapeRegExp = (string: string) => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
const regex = new RegExp(`(${highlightPhrases.map(escapeRegExp).join('|')})`, 'gi');
const highlightedHtml = html.replace(regex, (match) => `<span style="background-color: yellow;">${match}</span>`);
return { __html: highlightedHtml };
}, [html, highlightPhrases]);
return <div dangerouslySetInnerHTML={createHighlightedContent()} />;
};
export default HighlightedContent;

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { animated } from '@react-spring/web'; import { animated } from '@react-spring/web';
import { FaRegCirclePlay, FaRegCircleStop } from "react-icons/fa6"; import { FaRegCirclePlay, FaRegCircleStop } from "react-icons/fa6";
import HighlightedContent from './AnimatedHighlight'; import HighlightContent from '../HighlightContent';
import { ITrainingTip, SegmentRef, TimelineEvent } from './TrainingInterfaces'; import { ITrainingTip, SegmentRef, TimelineEvent } from './TrainingInterfaces';
@@ -267,7 +267,7 @@ const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
<div className='flex-1 bg-white p-6 rounded-lg shadow'> <div className='flex-1 bg-white p-6 rounded-lg shadow'>
{/*<h2 className="text-xl font-bold mb-4">Question</h2>*/} {/*<h2 className="text-xl font-bold mb-4">Question</h2>*/}
<div className="mb-4" dangerouslySetInnerHTML={{ __html: tip.exercise.question }} /> <div className="mb-4" dangerouslySetInnerHTML={{ __html: tip.exercise.question }} />
<HighlightedContent html={tip.exercise.highlightable} highlightPhrases={highlightedPhrases} /> <HighlightContent html={tip.exercise.highlightable} highlightPhrases={highlightedPhrases} />
</div> </div>
<div className='flex-1'> <div className='flex-1'>
<div className='bg-gray-50 rounded-lg shadow'> <div className='bg-gray-50 rounded-lg shadow'>

View File

@@ -525,7 +525,9 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<Checkbox <Checkbox
isChecked={!!expiryDate} isChecked={!!expiryDate}
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : null)} onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : null)}
disabled={disabled}> disabled={
disabled || (!["admin", "developer"].includes(loggedInUser.type) && !!loggedInUser.subscriptionExpirationDate)
}>
Enabled Enabled
</Checkbox> </Checkbox>
</div> </div>
@@ -564,7 +566,12 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
</div> </div>
</div> </div>
</div> </div>
{checkAccess(loggedInUser, ["developer", "admin"]) && ( {checkAccess(
loggedInUser,
["developer", "admin", "corporate", "mastercorporate"],
permissions,
user.type === "teacher" ? "editTeacher" : user.type === "student" ? "editStudent" : undefined,
) && (
<> <>
<Divider className="w-full !m-0" /> <Divider className="w-full !m-0" />
<div className="flex flex-col md:flex-row gap-8 w-full"> <div className="flex flex-col md:flex-row gap-8 w-full">
@@ -572,7 +579,10 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<label className="font-normal text-base text-mti-gray-dim">Status</label> <label className="font-normal text-base text-mti-gray-dim">Status</label>
<Select <Select
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none" className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={USER_STATUS_OPTIONS} options={USER_STATUS_OPTIONS.filter((x) => {
if (checkAccess(loggedInUser, ["admin", "developer"])) return true;
return x.value !== "paymentDue";
})}
menuPortalTarget={document?.body} menuPortalTarget={document?.body}
value={USER_STATUS_OPTIONS.find((o) => o.value === status)} value={USER_STATUS_OPTIONS.find((o) => o.value === status)}
onChange={(value) => setStatus(value?.value as typeof user.status)} onChange={(value) => setStatus(value?.value as typeof user.status)}
@@ -600,7 +610,28 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
<label className="font-normal text-base text-mti-gray-dim">Type</label> <label className="font-normal text-base text-mti-gray-dim">Type</label>
<Select <Select
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none" className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={USER_TYPE_OPTIONS} options={USER_TYPE_OPTIONS.filter((x) => {
if (x.value === "student")
return checkAccess(
loggedInUser,
["developer", "admin", "corporate", "mastercorporate"],
permissions,
"editStudent",
);
if (x.value === "teacher")
return checkAccess(
loggedInUser,
["developer", "admin", "corporate", "mastercorporate"],
permissions,
"editTeacher",
);
if (x.value === "corporate")
return checkAccess(loggedInUser, ["developer", "admin", "mastercorporate"], permissions, "editCorporate");
return checkAccess(loggedInUser, ["developer", "admin"]);
})}
menuPortalTarget={document?.body} menuPortalTarget={document?.body}
value={USER_TYPE_OPTIONS.find((o) => o.value === type)} value={USER_TYPE_OPTIONS.find((o) => o.value === type)}
onChange={(value) => setType(value?.value as typeof user.type)} onChange={(value) => setType(value?.value as typeof user.type)}

View File

@@ -0,0 +1,31 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border border-neutral-200 bg-white p-4 text-neutral-950 shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

File diff suppressed because it is too large Load Diff

View File

@@ -2,17 +2,12 @@
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import { User } from "@/interfaces/user"; import {User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList"; import UserList from "@/pages/(admin)/Lists/UserList";
import { dateSorter } from "@/utils"; import {dateSorter} from "@/utils";
import moment from "moment"; import moment from "moment";
import { useEffect, useState } from "react"; import {useEffect, useState} from "react";
import { import {BsArrowLeft, BsPersonFill, BsBank, BsCurrencyDollar} from "react-icons/bs";
BsArrowLeft,
BsPersonFill,
BsBank,
BsCurrencyDollar,
} from "react-icons/bs";
import UserCard from "@/components/UserCard"; import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
@@ -20,276 +15,235 @@ import IconCard from "./IconCard";
import usePaymentStatusUsers from "@/hooks/usePaymentStatusUsers"; import usePaymentStatusUsers from "@/hooks/usePaymentStatusUsers";
interface Props { interface Props {
user: User; user: User;
} }
export default function AgentDashboard({ user }: Props) { export default function AgentDashboard({user}: Props) {
const [page, setPage] = useState(""); const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>(); const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const { stats } = useStats(); const {stats} = useStats();
const { users, reload } = useUsers(); const {users, reload} = useUsers();
const { groups } = useGroups(user.id); const {pending, done} = usePaymentStatusUsers();
const { pending, done } = usePaymentStatusUsers();
useEffect(() => { useEffect(() => {
setShowModal(!!selectedUser && page === ""); setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]); }, [selectedUser, page]);
const corporateFilter = (user: User) => user.type === "corporate"; const corporateFilter = (user: User) => user.type === "corporate";
const referredCorporateFilter = (x: User) => const referredCorporateFilter = (x: User) =>
x.type === "corporate" && x.type === "corporate" && !!x.corporateInformation && x.corporateInformation.referralAgent === user.id;
!!x.corporateInformation && const inactiveReferredCorporateFilter = (x: User) =>
x.corporateInformation.referralAgent === user.id; referredCorporateFilter(x) && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
const inactiveReferredCorporateFilter = (x: User) =>
referredCorporateFilter(x) &&
(x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
const UserDisplay = ({ const UserDisplay = ({displayUser, allowClick = true}: {displayUser: User; allowClick?: boolean}) => (
displayUser, <div
allowClick = true, 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">
displayUser: User; <img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
allowClick?: boolean; <div className="flex flex-col gap-1 items-start">
}) => ( <span>
<div {displayUser.type === "corporate"
onClick={() => allowClick && setSelectedUser(displayUser)} ? displayUser.corporateInformation?.companyInformation?.name || displayUser.name
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300" : displayUser.name}
> </span>
<img <span className="text-sm opacity-75">{displayUser.email}</span>
src={displayUser.profilePicture} </div>
alt={displayUser.name} </div>
className="rounded-full w-10 h-10" );
/>
<div className="flex flex-col gap-1 items-start">
<span>
{displayUser.type === "corporate"
? displayUser.corporateInformation?.companyInformation?.name ||
displayUser.name
: displayUser.name}
</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
const ReferredCorporateList = () => { const ReferredCorporateList = () => {
return ( return (
<UserList <UserList
user={user} user={user}
filters={[referredCorporateFilter]} filters={[referredCorporateFilter]}
renderHeader={(total) => ( renderHeader={(total) => (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300" className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
> <BsArrowLeft className="text-xl" />
<BsArrowLeft className="text-xl" /> <span>Back</span>
<span>Back</span> </div>
</div> <h2 className="text-2xl font-semibold">Referred Corporate ({total})</h2>
<h2 className="text-2xl font-semibold"> </div>
Referred Corporate ({total}) )}
</h2> />
</div> );
)} };
/>
);
};
const InactiveReferredCorporateList = () => { const InactiveReferredCorporateList = () => {
return ( return (
<UserList <UserList
user={user} user={user}
filters={[inactiveReferredCorporateFilter]} filters={[inactiveReferredCorporateFilter]}
renderHeader={(total) => ( renderHeader={(total) => (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300" className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
> <BsArrowLeft className="text-xl" />
<BsArrowLeft className="text-xl" /> <span>Back</span>
<span>Back</span> </div>
</div> <h2 className="text-2xl font-semibold">Inactive Referred Corporate ({total})</h2>
<h2 className="text-2xl font-semibold"> </div>
Inactive Referred Corporate ({total}) )}
</h2> />
</div> );
)} };
/>
);
};
const CorporateList = () => { const CorporateList = () => {
const filter = (x: User) => x.type === "corporate"; const filter = (x: User) => x.type === "corporate";
return ( return (
<UserList <UserList
user={user} user={user}
filters={[filter]} filters={[filter]}
renderHeader={(total) => ( renderHeader={(total) => (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300" className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
> <BsArrowLeft className="text-xl" />
<BsArrowLeft className="text-xl" /> <span>Back</span>
<span>Back</span> </div>
</div> <h2 className="text-2xl font-semibold">Corporate ({total})</h2>
<h2 className="text-2xl font-semibold">Corporate ({total})</h2> </div>
</div> )}
)} />
/> );
); };
};
const CorporatePaidStatusList = ({ paid }: { paid: Boolean }) => { const CorporatePaidStatusList = ({paid}: {paid: Boolean}) => {
const list = paid ? done : pending; const list = paid ? done : pending;
const filter = (x: User) => x.type === "corporate" && list.includes(x.id); const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
return ( return (
<UserList <UserList
user={user} user={user}
filters={[filter]} filters={[filter]}
renderHeader={(total) => ( renderHeader={(total) => (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300" className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
> <BsArrowLeft className="text-xl" />
<BsArrowLeft className="text-xl" /> <span>Back</span>
<span>Back</span> </div>
</div> <h2 className="text-2xl font-semibold">
<h2 className="text-2xl font-semibold"> {paid ? "Payment Done" : "Pending Payment"} ({total})
{paid ? "Payment Done" : "Pending Payment"} ({total}) </h2>
</h2> </div>
</div> )}
)} />
/> );
); };
};
const DefaultDashboard = () => ( const DefaultDashboard = () => (
<> <>
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:gap-4 text-center"> <section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:gap-4 text-center">
<IconCard <IconCard
onClick={() => setPage("referredCorporate")} onClick={() => setPage("referredCorporate")}
Icon={BsBank} Icon={BsBank}
label="Referred Corporate" label="Referred Corporate"
value={users.filter(referredCorporateFilter).length} value={users.filter(referredCorporateFilter).length}
color="purple" color="purple"
/> />
<IconCard <IconCard
onClick={() => setPage("inactiveReferredCorporate")} onClick={() => setPage("inactiveReferredCorporate")}
Icon={BsBank} Icon={BsBank}
label="Inactive Referred Corporate" label="Inactive Referred Corporate"
value={users.filter(inactiveReferredCorporateFilter).length} value={users.filter(inactiveReferredCorporateFilter).length}
color="rose" color="rose"
/> />
<IconCard <IconCard
onClick={() => setPage("corporate")} onClick={() => setPage("corporate")}
Icon={BsBank} Icon={BsBank}
label="Corporate" label="Corporate"
value={users.filter(corporateFilter).length} value={users.filter(corporateFilter).length}
color="purple" color="purple"
/> />
<IconCard <IconCard onClick={() => setPage("paymentdone")} Icon={BsCurrencyDollar} label="Payment Done" value={done.length} color="purple" />
onClick={() => setPage("paymentdone")} <IconCard
Icon={BsCurrencyDollar} onClick={() => setPage("paymentpending")}
label="Payment Done" Icon={BsCurrencyDollar}
value={done.length} label="Pending Payment"
color="purple" value={pending.length}
/> color="rose"
<IconCard />
onClick={() => setPage("paymentpending")} </section>
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"> <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest Referred Corporate</span> <span className="p-4">Latest Referred Corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {users
.filter(referredCorporateFilter) .filter(referredCorporateFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) .sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => ( .map((x) => (
<UserDisplay key={x.id} displayUser={x} /> <UserDisplay key={x.id} displayUser={x} />
))} ))}
</div> </div>
</div> </div>
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest corporate</span> <span className="p-4">Latest corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {users
.filter(corporateFilter) .filter(corporateFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) .sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => ( .map((x) => (
<UserDisplay key={x.id} displayUser={x} allowClick={false} /> <UserDisplay key={x.id} displayUser={x} allowClick={false} />
))} ))}
</div> </div>
</div> </div>
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Referenced corporate expiring in 1 month</span> <span className="p-4">Referenced corporate expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {users
.filter( .filter(
(x) => (x) =>
referredCorporateFilter(x) && referredCorporateFilter(x) &&
moment().isAfter( moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
moment(x.subscriptionExpirationDate).subtract(30, "days") moment().isBefore(moment(x.subscriptionExpirationDate)),
) && )
moment().isBefore(moment(x.subscriptionExpirationDate)) .map((x) => (
) <UserDisplay key={x.id} displayUser={x} />
.map((x) => ( ))}
<UserDisplay key={x.id} displayUser={x} /> </div>
))} </div>
</div> </section>
</div> </>
</section> );
</>
);
return ( return (
<> <>
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}> <Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<> <>
{selectedUser && ( {selectedUser && (
<div className="w-full flex flex-col gap-8"> <div className="w-full flex flex-col gap-8">
<UserCard <UserCard
loggedInUser={user} loggedInUser={user}
onClose={(shouldReload) => { onClose={(shouldReload) => {
setSelectedUser(undefined); setSelectedUser(undefined);
if (shouldReload) reload(); if (shouldReload) reload();
}} }}
onViewStudents={ onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
selectedUser.type === "teacher" }
? () => setPage("students") onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
: undefined user={selectedUser}
} />
onViewTeachers={ </div>
selectedUser.type === "corporate" )}
? () => setPage("teachers") </>
: undefined </Modal>
} {page === "referredCorporate" && <ReferredCorporateList />}
user={selectedUser} {page === "corporate" && <CorporateList />}
/> {page === "inactiveReferredCorporate" && <InactiveReferredCorporateList />}
</div> {page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
)} {page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
</> {page === "" && <DefaultDashboard />}
</Modal> </>
{page === "referredCorporate" && <ReferredCorporateList />} );
{page === "corporate" && <CorporateList />}
{page === "inactiveReferredCorporate" && (
<InactiveReferredCorporateList />
)}
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
{page === "" && <DefaultDashboard />}
</>
);
} }

View File

@@ -11,8 +11,10 @@ import {useAssignmentArchive} from "@/hooks/useAssignmentArchive";
import {uniqBy} from "lodash"; import {uniqBy} from "lodash";
import {useAssignmentUnarchive} from "@/hooks/useAssignmentUnarchive"; import {useAssignmentUnarchive} from "@/hooks/useAssignmentUnarchive";
import {getUserName} from "@/utils/users"; import {getUserName} from "@/utils/users";
import {User} from "@/interfaces/user";
interface Props { interface Props {
users: User[];
onClick?: () => void; onClick?: () => void;
allowDownload?: boolean; allowDownload?: boolean;
reload?: Function; reload?: Function;
@@ -37,9 +39,8 @@ export default function AssignmentCard({
allowArchive, allowArchive,
allowUnarchive, allowUnarchive,
allowExcelDownload, allowExcelDownload,
users,
}: Assignment & Props) { }: Assignment & Props) {
const {users} = useUsers();
const renderPdfIcon = usePDFDownload("assignments"); const renderPdfIcon = usePDFDownload("assignments");
const renderExcelIcon = usePDFDownload("assignments", "excel"); const renderExcelIcon = usePDFDownload("assignments", "excel");
const renderArchiveIcon = useAssignmentArchive(id, reload); const renderArchiveIcon = useAssignmentArchive(id, reload);

View File

@@ -371,7 +371,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
!startDate || !startDate ||
!endDate || !endDate ||
assignees.length === 0 || assignees.length === 0 ||
(!!examIDs && examIDs.length < selectedModules.length) (!useRandomExams && examIDs.length < selectedModules.length)
} }
className="w-full max-w-[200px]" className="w-full max-w-[200px]"
onClick={createAssignment} onClick={createAssignment}

View File

@@ -29,7 +29,7 @@ import {
} from "react-icons/bs"; } from "react-icons/bs";
import UserCard from "@/components/UserCard"; import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import {calculateAverageLevel, calculateBandScore} from "@/utils/score"; import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score";
import {MODULE_ARRAY} from "@/utils/moduleUtils"; import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import {groupByExam} from "@/utils/stats"; import {groupByExam} from "@/utils/stats";
@@ -45,11 +45,113 @@ import AssignmentView from "./AssignmentView";
import AssignmentCreator from "./AssignmentCreator"; import AssignmentCreator from "./AssignmentCreator";
import clsx from "clsx"; import clsx from "clsx";
import AssignmentCard from "./AssignmentCard"; import AssignmentCard from "./AssignmentCard";
import {createColumnHelper} from "@tanstack/react-table";
import Checkbox from "@/components/Low/Checkbox";
import List from "@/components/List";
import {getUserCompanyName} from "@/resources/user";
interface Props { interface Props {
user: CorporateUser; user: CorporateUser;
} }
type StudentPerformanceItem = User & {corporateName: string; group: string};
const StudentPerformanceList = ({items, stats, users}: {items: StudentPerformanceItem[]; stats: Stat[]; users: User[]}) => {
const [isShowingAmount, setIsShowingAmount] = useState(false);
const columnHelper = createColumnHelper<StudentPerformanceItem>();
const columns = [
columnHelper.accessor("name", {
header: "Student Name",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("email", {
header: "E-mail",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("demographicInformation.passport_id", {
header: "ID",
cell: (info) => info.getValue() || "N/A",
}),
columnHelper.accessor("group", {
header: "Group",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("corporateName", {
header: "Corporate",
cell: (info) => info.getValue() || "N/A",
}),
columnHelper.accessor("levels.reading", {
header: "Reading",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.listening", {
header: "Listening",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.writing", {
header: "Writing",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.speaking", {
header: "Speaking",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.level", {
header: "Level",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels", {
id: "overall_level",
header: "Overall",
cell: (info) =>
!isShowingAmount
? averageLevelCalculator(
users,
stats.filter((x) => x.user === info.row.original.id),
).toFixed(1)
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`,
}),
];
return (
<div className="flex flex-col gap-4 w-full h-full">
<Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}>
Show Utilization
</Checkbox>
<List<StudentPerformanceItem>
data={items.sort(
(a, b) =>
averageLevelCalculator(
users,
stats.filter((x) => x.user === b.id),
) -
averageLevelCalculator(
users,
stats.filter((x) => x.user === a.id),
),
)}
columns={columns}
/>
</div>
);
};
export default function CorporateDashboard({user}: Props) { export default function CorporateDashboard({user}: Props) {
const [page, setPage] = useState(""); const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>(); const [selectedUser, setSelectedUser] = useState<User>();
@@ -57,11 +159,12 @@ export default function CorporateDashboard({user}: Props) {
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>(); const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>(); const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false); const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
const [userBalance, setUserBalance] = useState(0);
const {stats} = useStats(); const {stats} = useStats();
const {users, reload} = useUsers(); const {users, reload, isLoading} = useUsers();
const {codes} = useCodes(user.id); const {codes} = useCodes(user.id);
const {groups} = useGroups(user.id); const {groups} = useGroups({admin: user.id});
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id}); const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
const appendUserFilters = useFilterStore((state) => state.appendUserFilter); const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
@@ -71,6 +174,14 @@ export default function CorporateDashboard({user}: Props) {
setShowModal(!!selectedUser && page === ""); setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]); }, [selectedUser, page]);
useEffect(() => {
const relatedGroups = groups.filter((x) => x.name === "Students" || x.name === "Teachers" || x.name === "Corporate");
const usersInGroups = relatedGroups.map((x) => x.participants).flat();
const filteredCodes = codes.filter((x) => !x.userId || !usersInGroups.includes(x.userId));
setUserBalance(usersInGroups.length + filteredCodes.length);
}, [codes, groups]);
useEffect(() => { useEffect(() => {
// in this case it fetches the master corporate account // in this case it fetches the master corporate account
getUserCorporate(user.id).then(setCorporateUserToShow); getUserCorporate(user.id).then(setCorporateUserToShow);
@@ -228,7 +339,7 @@ export default function CorporateDashboard({user}: Props) {
<h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeFilter).length})</h2> <h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeFilter).length})</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{assignments.filter(activeFilter).map((a) => ( {assignments.filter(activeFilter).map((a) => (
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} /> <AssignmentCard {...a} users={users} onClick={() => setSelectedAssignment(a)} key={a.id} />
))} ))}
</div> </div>
</section> </section>
@@ -244,6 +355,7 @@ export default function CorporateDashboard({user}: Props) {
{assignments.filter(futureFilter).map((a) => ( {assignments.filter(futureFilter).map((a) => (
<AssignmentCard <AssignmentCard
{...a} {...a}
users={users}
onClick={() => { onClick={() => {
setSelectedAssignment(a); setSelectedAssignment(a);
setIsCreatingAssignment(true); setIsCreatingAssignment(true);
@@ -259,6 +371,7 @@ export default function CorporateDashboard({user}: Props) {
{assignments.filter(pastFilter).map((a) => ( {assignments.filter(pastFilter).map((a) => (
<AssignmentCard <AssignmentCard
{...a} {...a}
users={users}
onClick={() => setSelectedAssignment(a)} onClick={() => setSelectedAssignment(a)}
key={a.id} key={a.id}
allowDownload allowDownload
@@ -275,6 +388,7 @@ export default function CorporateDashboard({user}: Props) {
{assignments.filter(archivedFilter).map((a) => ( {assignments.filter(archivedFilter).map((a) => (
<AssignmentCard <AssignmentCard
{...a} {...a}
users={users}
onClick={() => setSelectedAssignment(a)} onClick={() => setSelectedAssignment(a)}
key={a.id} key={a.id}
allowDownload allowDownload
@@ -289,6 +403,36 @@ export default function CorporateDashboard({user}: Props) {
); );
}; };
const StudentPerformancePage = () => {
const students = users
.filter((x) => x.type === "student" && groups.flatMap((g) => g.participants).includes(x.id))
.map((u) => ({
...u,
group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A",
corporateName: getUserCompanyName(u, users, groups),
}));
return (
<>
<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={reload}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<span>Reload</span>
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
</div>
</div>
<StudentPerformanceList items={students} stats={stats} users={users} />
</>
);
};
const averageLevelCalculator = (studentStats: Stat[]) => { const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats const formattedStats = studentStats
.map((s) => ({ .map((s) => ({
@@ -352,7 +496,7 @@ export default function CorporateDashboard({user}: Props) {
<IconCard <IconCard
Icon={BsPersonCheck} Icon={BsPersonCheck}
label="User Balance" label="User Balance"
value={`${codes.length}/${user.corporateInformation?.companyInformation?.userAmount || 0}`} value={`${userBalance}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
color="purple" color="purple"
/> />
<IconCard <IconCard
@@ -361,6 +505,13 @@ export default function CorporateDashboard({user}: Props) {
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"} value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
color="rose" color="rose"
/> />
<IconCard
Icon={BsPersonFillGear}
label="Student Performance"
value={users.filter(studentFilter).length}
color="purple"
onClick={() => setPage("studentsPerformance")}
/>
<button <button
disabled={isAssignmentsLoading} disabled={isAssignmentsLoading}
onClick={() => setPage("assignments")} onClick={() => setPage("assignments")}
@@ -489,6 +640,7 @@ export default function CorporateDashboard({user}: Props) {
{page === "teachers" && <TeachersList />} {page === "teachers" && <TeachersList />}
{page === "groups" && <GroupsList />} {page === "groups" && <GroupsList />}
{page === "assignments" && <AssignmentsPage />} {page === "assignments" && <AssignmentsPage />}
{page === "studentsPerformance" && <StudentPerformancePage />}
{page === "" && <DefaultDashboard />} {page === "" && <DefaultDashboard />}
</> </>
); );

View File

@@ -1,140 +1,108 @@
import React from "react"; import React from "react";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import { User } from "@/interfaces/user"; import {User} from "@/interfaces/user";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import { import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
BsBook, import {MODULE_ARRAY} from "@/utils/moduleUtils";
BsClipboard, import {capitalize} from "lodash";
BsHeadphones, import {getLevelLabel} from "@/utils/score";
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 }) => { const Card = ({user}: {user: User}) => {
return ( return (
<div className="border-mti-gray-platinum flex flex-col h-fit w-full cursor-pointer flex-col gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow"> <div className="border-mti-gray-platinum flex flex-col h-fit w-full cursor-pointer gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<h3 className="text-xl font-semibold">{user.name}</h3> <h3 className="text-xl font-semibold">{user.name}</h3>
</div> </div>
<div className="flex w-full gap-3 flex-wrap"> <div className="flex w-full gap-3 flex-wrap">
{MODULE_ARRAY.map((module) => { {MODULE_ARRAY.map((module) => {
const desiredLevel = user.desiredLevels[module] || 9; const desiredLevel = user.desiredLevels[module] || 9;
const level = user.levels[module] || 0; const level = user.levels[module] || 0;
return ( return (
<div <div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4 min-w-[250px]" key={module}>
className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4 min-w-[250px]" <div className="flex items-center gap-2 md:gap-3">
key={module} <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" />}
<div className="flex items-center gap-2 md:gap-3"> {module === "listening" && <BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />}
<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 === "writing" && <BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />}
{module === "reading" && ( {module === "speaking" && <BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />}
<BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" /> {module === "level" && <BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />}
)} </div>
{module === "listening" && ( <div className="flex w-full flex-col">
<BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" /> <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 === "writing" && ( {module === "level" && <span>English Level: {getLevelLabel(level).join(" / ")}</span>}
<BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" /> {module !== "level" && (
)} <div className="flex flex-col">
{module === "speaking" && ( <span>Level {level} / Level 9</span>
<BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" /> <span>Desired Level: {desiredLevel}</span>
)} </div>
{module === "level" && ( )}
<BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" /> </div>
)} </div>
</div> </div>
<div className="flex w-full flex-col"> <div className="md:pl-14">
<span className="text-sm font-bold md:font-extrabold w-full"> <ProgressBar
{capitalize(module)} color={module}
</span> label=""
<div className="text-mti-gray-dim text-sm font-normal"> mark={Math.round((desiredLevel * 100) / 9)}
{module === "level" && ( markLabel={`Desired Level: ${desiredLevel}`}
<span> percentage={Math.round((level * 100) / 9)}
English Level: {getLevelLabel(level).join(" / ")} className="h-2 w-full"
</span> />
)} </div>
{module !== "level" && ( </div>
<div className="flex flex-col"> );
<span>Level {level} / Level 9</span> })}
<span>Desired Level: {desiredLevel}</span> </div>
</div> </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 CorporateStudentsLevels = () => {
const { users } = useUsers(); const {users} = useUsers();
const { groups } = useGroups(); const {groups} = useGroups({});
const corporateUsers = users.filter((u) => u.type === "corporate") as User[]; const corporateUsers = users.filter((u) => u.type === "corporate") as User[];
const [corporateId, setCorporateId] = React.useState<string>(""); const [corporateId, setCorporateId] = React.useState<string>("");
const corporate = const corporate = corporateUsers.find((u) => u.id === corporateId) || corporateUsers[0];
corporateUsers.find((u) => u.id === corporateId) || corporateUsers[0];
const groupsFromCorporate = corporate const groupsFromCorporate = corporate ? groups.filter((g) => g.admin === corporate.id) : [];
? groups.filter((g) => g.admin === corporate.id)
: [];
const groupsParticipants = groupsFromCorporate const groupsParticipants = groupsFromCorporate
.flatMap((g) => g.participants) .flatMap((g) => g.participants)
.reduce((accm: User[], p) => { .reduce((accm: User[], p) => {
const user = users.find((u) => u.id === p) as User; const user = users.find((u) => u.id === p) as User;
if (user) { if (user) {
return [...accm, user]; return [...accm, user];
} }
return accm; return accm;
}, []); }, []);
return ( return (
<> <>
<Select <Select
options={corporateUsers.map((x: User) => ({ options={corporateUsers.map((x: User) => ({
value: x.id, value: x.id,
label: `${x.name} - ${x.email}`, label: `${x.name} - ${x.email}`,
}))} }))}
value={corporate ? { value: corporate.id, label: corporate.name } : null} value={corporate ? {value: corporate.id, label: corporate.name} : null}
onChange={(value) => setCorporateId(value?.value!)} onChange={(value) => setCorporateId(value?.value!)}
styles={{ styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
? "#D5D9F0" color: state.isFocused ? "black" : styles.color,
: state.isSelected }),
? "#7872BF" }}
: "white", />
color: state.isFocused ? "black" : styles.color, {groupsParticipants.map((u) => (
}), <Card user={u} key={u.id} />
}} ))}
/> </>
{groupsParticipants.map((u) => ( );
<Card user={u} key={u.id} />
))}
</>
);
}; };
export default CorporateStudentsLevels; export default CorporateStudentsLevels;

View File

@@ -2,39 +2,35 @@
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import { import {CorporateUser, Group, MasterCorporateUser, Stat, User} from "@/interfaces/user";
Group,
MasterCorporateUser,
Stat,
User,
CorporateUser,
} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList"; import UserList from "@/pages/(admin)/Lists/UserList";
import { dateSorter } from "@/utils"; import { dateSorter } from "@/utils";
import moment from "moment"; import moment from "moment";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
BsArrowLeft, BsArrowLeft,
BsClipboard2Data, BsClipboard2Data,
BsClock, BsClock,
BsPaperclip, BsPaperclip,
BsPersonFill, BsPersonFill,
BsPencilSquare, BsPencilSquare,
BsPersonCheck, BsPersonCheck,
BsPeople, BsPeople,
BsBank, BsBank,
BsEnvelopePaper, BsEnvelopePaper,
BsArrowRepeat, BsArrowRepeat,
BsPlus, BsPlus,
BsDatabase, BsPersonFillGear,
BsFilter,
BsDatabase
} from "react-icons/bs"; } from "react-icons/bs";
import UserCard from "@/components/UserCard"; import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import { calculateAverageLevel, calculateBandScore } from "@/utils/score"; import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score";
import { MODULE_ARRAY } from "@/utils/moduleUtils"; import {MODULE_ARRAY} from "@/utils/moduleUtils";
import { Module } from "@/interfaces"; import {Module} from "@/interfaces";
import { groupByExam } from "@/utils/stats"; import {groupByExam} from "@/utils/stats";
import IconCard from "./IconCard"; import IconCard from "./IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList"; import GroupList from "@/pages/(admin)/Lists/GroupList";
import useFilterStore from "@/stores/listFilterStore"; import useFilterStore from "@/stores/listFilterStore";
@@ -46,28 +42,279 @@ import AssignmentView from "./AssignmentView";
import AssignmentCreator from "./AssignmentCreator"; import AssignmentCreator from "./AssignmentCreator";
import clsx from "clsx"; import clsx from "clsx";
import AssignmentCard from "./AssignmentCard"; import AssignmentCard from "./AssignmentCard";
import {createColumn, createColumnHelper} from "@tanstack/react-table";
import List from "@/components/List";
import {getUserCorporate} from "@/utils/groups";
import {getCorporateUser, getUserCompanyName} from "@/resources/user";
import Checkbox from "@/components/Low/Checkbox";
import {groupBy, uniq, uniqBy} from "lodash";
import Select from "@/components/Low/Select";
import {Menu, MenuButton, MenuItem, MenuItems} from "@headlessui/react";
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
import MasterStatistical from "./MasterStatistical"; import MasterStatistical from "./MasterStatistical";
interface Props { interface Props {
user: MasterCorporateUser; user: MasterCorporateUser;
} }
export default function MasterCorporateDashboard({ user }: Props) { const activeFilter = (a: Assignment) =>
const [page, setPage] = useState(""); moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length;
const [selectedUser, setSelectedUser] = useState<User>(); const pastFilter = (a: Assignment) => (moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length) && !a.archived;
const [showModal, setShowModal] = useState(false); const archivedFilter = (a: Assignment) => a.archived;
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>(); const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
const { stats } = useStats(); type StudentPerformanceItem = User & {corporate?: CorporateUser; group?: Group};
const { users, reload } = useUsers(); const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPerformanceItem[]; stats: Stat[]; users: User[]; groups: Group[]}) => {
const { codes } = useCodes(user.id); const [isShowingAmount, setIsShowingAmount] = useState(false);
const { groups } = useGroups(user.id, user.type); const [availableCorporates] = useState(
uniqBy(
items.map((x) => x.corporate),
"id",
),
);
const [availableGroups] = useState(
uniqBy(
items.map((x) => x.group),
"id",
),
);
const [selectedCorporate, setSelectedCorporate] = useState<CorporateUser | null | undefined>(null);
const [selectedGroup, setSelectedGroup] = useState<Group | null | undefined>(null);
const columnHelper = createColumnHelper<StudentPerformanceItem>();
const columns = [
columnHelper.accessor("name", {
header: "Student Name",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("email", {
header: "E-mail",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("demographicInformation.passport_id", {
header: "ID",
cell: (info) => info.getValue() || "N/A",
}),
columnHelper.accessor("group", {
header: "Group",
cell: (info) => info.getValue()?.name || "N/A",
}),
columnHelper.accessor("corporate", {
header: "Corporate",
cell: (info) => (!!info.getValue() ? getUserCompanyName(info.getValue() as User, users, groups) : "N/A"),
}),
columnHelper.accessor("levels.reading", {
header: "Reading",
cell: (info) =>
!isShowingAmount
? calculateBandScore(
stats
.filter((x) => x.module === "reading" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.correct, 0),
stats
.filter((x) => x.module === "reading" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.total, 0),
"level",
info.row.original.focus || "academic",
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.listening", {
header: "Listening",
cell: (info) =>
!isShowingAmount
? calculateBandScore(
stats
.filter((x) => x.module === "listening" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.correct, 0),
stats
.filter((x) => x.module === "listening" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.total, 0),
"level",
info.row.original.focus || "academic",
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.writing", {
header: "Writing",
cell: (info) =>
!isShowingAmount
? calculateBandScore(
stats
.filter((x) => x.module === "writing" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.correct, 0),
stats
.filter((x) => x.module === "writing" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.total, 0),
"level",
info.row.original.focus || "academic",
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.speaking", {
header: "Speaking",
cell: (info) =>
!isShowingAmount
? calculateBandScore(
stats
.filter((x) => x.module === "speaking" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.correct, 0),
stats
.filter((x) => x.module === "speaking" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.total, 0),
"level",
info.row.original.focus || "academic",
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels.level", {
header: "Level",
cell: (info) =>
!isShowingAmount
? calculateBandScore(
stats
.filter((x) => x.module === "level" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.correct, 0),
stats
.filter((x) => x.module === "level" && x.user === info.row.original.id)
.reduce((acc, curr) => acc + curr.score.total, 0),
"level",
info.row.original.focus || "academic",
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`,
}),
columnHelper.accessor("levels", {
id: "overall_level",
header: "Overall",
cell: (info) =>
!isShowingAmount
? averageLevelCalculator(
users,
stats.filter((x) => x.user === info.row.original.id),
).toFixed(1)
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`,
}),
];
const filterUsers = (data: StudentPerformanceItem[]) => {
console.log(data, selectedCorporate);
const filterByCorporate = (item: StudentPerformanceItem) => item.corporate?.id === selectedCorporate?.id;
const filterByGroup = (item: StudentPerformanceItem) => item.group?.id === selectedGroup?.id;
const filters: ((item: StudentPerformanceItem) => boolean)[] = [];
if (selectedCorporate !== null) filters.push(filterByCorporate);
if (selectedGroup !== null) filters.push(filterByGroup);
return filters.reduce((d, f) => d.filter(f), data);
};
return (
<div className="flex flex-col gap-4 w-full h-full">
<div className="w-full flex gap-4 justify-between items-center">
<Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}>
Show Utilization
</Checkbox>
<Popover>
<PopoverTrigger>
<div className="flex items-center justify-center p-2 hover:bg-neutral-300/50 rounded-full transition ease-in-out duration-300">
<BsFilter size={20} />
</div>
</PopoverTrigger>
<PopoverContent className="w-96">
<div className="flex flex-col gap-4">
<span className="font-bold text-lg">Filters</span>
<Select
options={availableCorporates.map((x) => ({
value: x?.id || "N/A",
label: x?.corporateInformation?.companyInformation?.name || x?.name || "N/A",
}))}
isClearable
value={
selectedCorporate === null
? null
: {
value: selectedCorporate?.id || "N/A",
label:
selectedCorporate?.corporateInformation?.companyInformation?.name ||
selectedCorporate?.name ||
"N/A",
}
}
placeholder="Select a Corporate..."
onChange={(value) =>
!value
? setSelectedCorporate(null)
: setSelectedCorporate(
value.value === "N/A" ? undefined : availableCorporates.find((x) => x?.id === value.value),
)
}
/>
<Select
options={availableGroups.map((x) => ({
value: x?.id || "N/A",
label: x?.name || "N/A",
}))}
isClearable
value={
selectedGroup === null
? null
: {
value: selectedGroup?.id || "N/A",
label: selectedGroup?.name || "N/A",
}
}
placeholder="Select a Group..."
onChange={(value) =>
!value
? setSelectedGroup(null)
: setSelectedGroup(value.value === "N/A" ? undefined : availableGroups.find((x) => x?.id === value.value))
}
/>
</div>
</PopoverContent>
</Popover>
</div>
<List<StudentPerformanceItem>
data={filterUsers(
items.sort(
(a, b) =>
averageLevelCalculator(
users,
stats.filter((x) => x.user === b.id),
) -
averageLevelCalculator(
users,
stats.filter((x) => x.user === a.id),
),
),
)}
columns={columns}
/>
</div>
);
};
export default function MasterCorporateDashboard({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 [corporateAssignments, setCorporateAssignments] = useState<(Assignment & {corporate?: CorporateUser})[]>([]);
const {stats} = useStats();
const {users, reload} = useUsers();
const {codes} = useCodes(user.id);
const {groups} = useGroups({admin: user.id, userType: user.type});
const masterCorporateUserGroups = [ const masterCorporateUserGroups = [
...new Set( ...new Set(
groups.filter((u) => u.admin === user.id).flatMap((g) => g.participants) groups.filter((u) => u.admin === user.id).flatMap((g) => g.participants)
), ),
]; ];
const corporateUserGroups = [ const corporateUserGroups = [
...new Set(groups.flatMap((g) => g.participants)), ...new Set(groups.flatMap((g) => g.participants)),
]; ];
@@ -85,13 +332,20 @@ export default function MasterCorporateDashboard({ user }: Props) {
setShowModal(!!selectedUser && page === ""); setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]); }, [selectedUser, page]);
const studentFilter = (user: User) => useEffect(() => {
user.type === "student" && corporateUserGroups.includes(user.id); setCorporateAssignments(
const teacherFilter = (user: User) => assignments.filter(activeFilter).map((a) => ({
user.type === "teacher" && corporateUserGroups.includes(user.id); ...a,
corporate: !!users.find((x) => x.id === a.assigner)
? getCorporateUser(users.find((x) => x.id === a.assigner)!, users, groups)
: undefined,
})),
);
}, [assignments, groups, users]);
const getStatsByStudent = (user: User) => const studentFilter = (user: User) => user.type === "student" && corporateUserGroups.includes(user.id);
stats.filter((s) => s.user === 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) => ( const UserDisplay = (displayUser: User) => (
<div <div
@@ -210,149 +464,179 @@ export default function MasterCorporateDashboard({ user }: Props) {
); );
}; };
const AssignmentsPage = () => { // const AssignmentsPage = () => {
const activeFilter = (a: Assignment) => // const activeFilter = (a: Assignment) =>
moment(a.endDate).isAfter(moment()) && // moment(a.endDate).isAfter(moment()) &&
moment(a.startDate).isBefore(moment()) && // moment(a.startDate).isBefore(moment()) &&
a.assignees.length > a.results.length; // a.assignees.length > a.results.length;
const pastFilter = (a: Assignment) => // const pastFilter = (a: Assignment) =>
(moment(a.endDate).isBefore(moment()) || // (moment(a.endDate).isBefore(moment()) ||
a.assignees.length === a.results.length) && // a.assignees.length === a.results.length) &&
!a.archived; // !a.archived;
const archivedFilter = (a: Assignment) => a.archived; // const archivedFilter = (a: Assignment) => a.archived;
const futureFilter = (a: Assignment) => // const futureFilter = (a: Assignment) =>
moment(a.startDate).isAfter(moment()); // moment(a.startDate).isAfter(moment());
return ( const StudentPerformancePage = () => {
<> const students = users
<AssignmentView .filter((x) => x.type === "student" && groups.flatMap((g) => g.participants).includes(x.id))
isOpen={!!selectedAssignment && !isCreatingAssignment} .map((u) => ({
onClose={() => { ...u,
setSelectedAssignment(undefined); group: groups.find((x) => x.participants.includes(u.id)),
setIsCreatingAssignment(false); corporate: getCorporateUser(u, users, groups),
reloadAssignments(); }));
}}
assignment={selectedAssignment} return (
/> <>
<AssignmentCreator <div className="w-full flex justify-between items-center">
assignment={selectedAssignment} <div
groups={groups.filter( onClick={() => setPage("")}
(x) => x.admin === user.id || x.participants.includes(user.id) 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" />
users={users.filter( <span>Back</span>
(x) => </div>
x.type === "student" && <div
(!!selectedUser onClick={reloadAssignments}
? groups className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
.filter((g) => g.admin === selectedUser.id) <span>Reload</span>
.flatMap((g) => g.participants) <BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
.includes(x.id) || false </div>
: groups.flatMap((g) => g.participants).includes(x.id)) </div>
)} <StudentPerformanceList items={students} stats={stats} users={users} groups={groups} />
assigner={user.id} </>
isCreating={isCreatingAssignment} );
cancelCreation={() => { };
setIsCreatingAssignment(false);
setSelectedAssignment(undefined); const AssignmentsPage = () => {
reloadAssignments(); return (
}} <>
/> <AssignmentView
<div className="w-full flex justify-between items-center"> isOpen={!!selectedAssignment && !isCreatingAssignment}
<div onClose={() => {
onClick={() => setPage("")} setSelectedAssignment(undefined);
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300" setIsCreatingAssignment(false);
> reloadAssignments();
<BsArrowLeft className="text-xl" /> }}
<span>Back</span> assignment={selectedAssignment}
</div> />
<div <AssignmentCreator
onClick={reloadAssignments} assignment={selectedAssignment}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300" groups={groups.filter((x) => x.admin === user.id || x.participants.includes(user.id))}
> users={users.filter(
<span>Reload</span> (x) =>
<BsArrowRepeat x.type === "student" &&
className={clsx( (!!selectedUser
"text-xl", ? groups
isAssignmentsLoading && "animate-spin" .filter((g) => g.admin === selectedUser.id)
)} .flatMap((g) => g.participants)
/> .includes(x.id) || false
</div> : groups.flatMap((g) => g.participants).includes(x.id)),
</div> )}
<section className="flex flex-col gap-4"> assigner={user.id}
<h2 className="text-2xl font-semibold"> isCreating={isCreatingAssignment}
Active Assignments ({assignments.filter(activeFilter).length}) cancelCreation={() => {
</h2> setIsCreatingAssignment(false);
<div className="flex flex-wrap gap-2"> setSelectedAssignment(undefined);
{assignments.filter(activeFilter).map((a) => ( reloadAssignments();
<AssignmentCard }}
{...a} />
onClick={() => setSelectedAssignment(a)} <div className="w-full flex justify-between items-center">
key={a.id} <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">
</div> <BsArrowLeft className="text-xl" />
</section> <span>Back</span>
<section className="flex flex-col gap-4"> </div>
<h2 className="text-2xl font-semibold"> <div
Planned Assignments ({assignments.filter(futureFilter).length}) onClick={reloadAssignments}
</h2> className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<div className="flex flex-wrap gap-2"> <span>Reload</span>
<div <BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
onClick={() => setIsCreatingAssignment(true)} </div>
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" </div>
> <div className="flex flex-col gap-2">
<BsPlus className="text-6xl" /> <span className="text-lg font-bold">Active Assignments Status</span>
<span className="text-lg">New Assignment</span> <div className="flex items-center gap-4">
</div> <span>
{assignments.filter(futureFilter).map((a) => ( <b>Total:</b> {assignments.filter(activeFilter).reduce((acc, curr) => acc + curr.results.length, 0)}/
<AssignmentCard {assignments.filter(activeFilter).reduce((acc, curr) => curr.exams.length + acc, 0)}
{...a} </span>
onClick={() => { {Object.keys(groupBy(corporateAssignments, (x) => x.corporate?.id)).map((x) => (
setSelectedAssignment(a); <div key={x}>
setIsCreatingAssignment(true); <span className="font-semibold">{getUserCompanyName(users.find((u) => u.id === x)!, users, groups)}: </span>
}} <span>
key={a.id} {groupBy(corporateAssignments, (x) => x.corporate?.id)[x].reduce((acc, curr) => curr.results.length + acc, 0)}/
/> {groupBy(corporateAssignments, (x) => x.corporate?.id)[x].reduce((acc, curr) => curr.exams.length + acc, 0)}
))} </span>
</div> </div>
</section> ))}
<section className="flex flex-col gap-4"> </div>
<h2 className="text-2xl font-semibold"> </div>
Past Assignments ({assignments.filter(pastFilter).length}) <section className="flex flex-col gap-4">
</h2> <h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeFilter).length})</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{assignments.filter(pastFilter).map((a) => ( {assignments.filter(activeFilter).map((a) => (
<AssignmentCard <AssignmentCard {...a} users={users} onClick={() => setSelectedAssignment(a)} key={a.id} />
{...a} ))}
onClick={() => setSelectedAssignment(a)} </div>
key={a.id} </section>
allowDownload <section className="flex flex-col gap-4">
reload={reloadAssignments} <h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureFilter).length})</h2>
allowArchive <div className="flex flex-wrap gap-2">
/> <div
))} onClick={() => setIsCreatingAssignment(true)}
</div> 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">
</section> <BsPlus className="text-6xl" />
<section className="flex flex-col gap-4"> <span className="text-lg">New Assignment</span>
<h2 className="text-2xl font-semibold"> </div>
Archived Assignments ({assignments.filter(archivedFilter).length}) {assignments.filter(futureFilter).map((a) => (
</h2> <AssignmentCard
<div className="flex flex-wrap gap-2"> {...a}
{assignments.filter(archivedFilter).map((a) => ( users={users}
<AssignmentCard onClick={() => {
{...a} setSelectedAssignment(a);
onClick={() => setSelectedAssignment(a)} setIsCreatingAssignment(true);
key={a.id} }}
allowDownload key={a.id}
reload={reloadAssignments} />
allowUnarchive ))}
/> </div>
))} </section>
</div> <section className="flex flex-col gap-4">
</section> <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}
users={users}
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}
users={users}
onClick={() => setSelectedAssignment(a)}
key={a.id}
allowDownload
reload={reloadAssignments}
allowUnarchive
/>
))}
</div>
</section>
</>
);
};
const MasterStatisticalPage = () => { const MasterStatisticalPage = () => {
return ( return (
@@ -381,128 +665,85 @@ export default function MasterCorporateDashboard({ user }: Props) {
); );
}; };
const averageLevelCalculator = (studentStats: Stat[]) => { const DefaultDashboard = () => (
const formattedStats = studentStats <>
.map((s) => ({ <section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center">
focus: users.find((u) => u.id === s.user)?.focus, <IconCard
score: s.score, onClick={() => setPage("students")}
module: s.module, Icon={BsPersonFill}
})) label="Students"
.filter((f) => !!f.focus); value={users.filter(studentFilter).length}
const bandScores = formattedStats.map((s) => ({ color="purple"
module: s.module, />
level: calculateBandScore( <IconCard
s.score.correct, onClick={() => setPage("teachers")}
s.score.total, Icon={BsPencilSquare}
s.module, label="Teachers"
s.focus! value={users.filter(teacherFilter).length}
), color="purple"
})); />
<IconCard
const levels: { [key in Module]: number } = { Icon={BsClipboard2Data}
reading: 0, label="Exams Performed"
listening: 0, value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
writing: 0, color="purple"
speaking: 0, />
level: 0, <IconCard
}; Icon={BsPaperclip}
bandScores.forEach((b) => (levels[b.module] += b.level)); label="Average Level"
value={averageLevelCalculator(
return calculateAverageLevel(levels); users,
}; stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)),
).toFixed(1)}
const DefaultDashboard = () => ( color="purple"
<> />
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center"> <IconCard onClick={() => setPage("groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
<IconCard <IconCard
onClick={() => setPage("students")} Icon={BsPersonCheck}
Icon={BsPersonFill} label="User Balance"
label="Students" value={`${codes.length}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
value={users.filter(studentFilter).length} color="purple"
color="purple" />
/> <IconCard
<IconCard Icon={BsClock}
onClick={() => setPage("teachers")} label="Expiration Date"
Icon={BsPencilSquare} value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
label="Teachers" color="rose"
value={users.filter(teacherFilter).length} />
color="purple" <IconCard
/> Icon={BsBank}
<IconCard label="Corporate"
Icon={BsClipboard2Data} value={masterCorporateUserGroups.length}
label="Exams Performed" color="purple"
value={ onClick={() => setPage("corporate")}
stats.filter((s) => />
groups.flatMap((g) => g.participants).includes(s.user) <IconCard
).length Icon={BsPersonFillGear}
} label="Student Performance"
color="purple" value={users.filter(studentFilter).length}
/> color="purple"
<IconCard onClick={() => setPage("studentsPerformance")}
Icon={BsPaperclip} />
label="Average Level" <IconCard
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")}
/>
<IconCard
Icon={BsDatabase} Icon={BsDatabase}
label="Master Statistical" label="Master Statistical"
// value={masterCorporateUserGroups.length} // value={masterCorporateUserGroups.length}
color="purple" color="purple"
onClick={() => setPage("statistical")} onClick={() => setPage("statistical")}
/> />
<button <button
disabled={isAssignmentsLoading} disabled={isAssignmentsLoading}
onClick={() => setPage("assignments")} onClick={() => setPage("assignments")}
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300" className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
> <BsEnvelopePaper className="text-6xl text-mti-purple-light" />
<BsEnvelopePaper className="text-6xl text-mti-purple-light" /> <span className="flex flex-col gap-1 items-center text-xl">
<span className="flex flex-col gap-1 items-center text-xl"> <span className="text-lg">Assignments</span>
<span className="text-lg">Assignments</span> <span className="font-semibold text-mti-purple-light">
<span className="font-semibold text-mti-purple-light"> {isAssignmentsLoading ? "Loading..." : assignments.filter((a) => !a.archived).length}
{isAssignmentsLoading </span>
? "Loading..." </span>
: assignments.filter((a) => !a.archived).length} </button>
</span> </section>
</span>
</button>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between"> <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
@@ -619,23 +860,24 @@ export default function MasterCorporateDashboard({ user }: Props) {
.includes(x.id), .includes(x.id),
}); });
router.push("/list/users"); router.push("/list/users");
} }
: undefined : undefined
} }
user={selectedUser} user={selectedUser}
/> />
</div> </div>
)} )}
</> </>
</Modal> </Modal>
{page === "students" && <StudentsList />} {page === "students" && <StudentsList />}
{page === "teachers" && <TeachersList />} {page === "teachers" && <TeachersList />}
{page === "groups" && <GroupsList />} {page === "groups" && <GroupsList />}
{page === "corporate" && <CorporateList />} {page === "corporate" && <CorporateList />}
{page === "assignments" && <AssignmentsPage />} {page === "assignments" && <AssignmentsPage />}
{page === "studentsPerformance" && <StudentPerformancePage />}
{page === "statistical" && <MasterStatisticalPage />} {page === "statistical" && <MasterStatisticalPage />}
{page === "" && <DefaultDashboard />} {page === "" && <DefaultDashboard />}
</> </>
); );
} }

View File

@@ -63,7 +63,7 @@ export default function TeacherDashboard({user}: Props) {
const {stats} = useStats(); const {stats} = useStats();
const {users, reload} = useUsers(); const {users, reload} = useUsers();
const {groups} = useGroups(user.id); const {groups} = useGroups({adminAdmins: user.id});
const {permissions} = usePermissions(user.id); const {permissions} = usePermissions(user.id);
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id}); const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id});
@@ -193,7 +193,7 @@ export default function TeacherDashboard({user}: Props) {
? groups ? groups
.filter((g) => g.admin === selectedUser.id) .filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants) .flatMap((g) => g.participants)
.includes(x.id) || false .includes(x.id)
: groups.flatMap((g) => g.participants).includes(x.id)), : groups.flatMap((g) => g.participants).includes(x.id)),
)} )}
assigner={user.id} assigner={user.id}
@@ -222,7 +222,7 @@ export default function TeacherDashboard({user}: Props) {
<h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeFilter).length})</h2> <h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeFilter).length})</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{assignments.filter(activeFilter).map((a) => ( {assignments.filter(activeFilter).map((a) => (
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} /> <AssignmentCard {...a} users={users} onClick={() => setSelectedAssignment(a)} key={a.id} />
))} ))}
</div> </div>
</section> </section>
@@ -238,6 +238,7 @@ export default function TeacherDashboard({user}: Props) {
{assignments.filter(futureFilter).map((a) => ( {assignments.filter(futureFilter).map((a) => (
<AssignmentCard <AssignmentCard
{...a} {...a}
users={users}
onClick={() => { onClick={() => {
setSelectedAssignment(a); setSelectedAssignment(a);
setIsCreatingAssignment(true); setIsCreatingAssignment(true);
@@ -253,6 +254,7 @@ export default function TeacherDashboard({user}: Props) {
{assignments.filter(pastFilter).map((a) => ( {assignments.filter(pastFilter).map((a) => (
<AssignmentCard <AssignmentCard
{...a} {...a}
users={users}
onClick={() => setSelectedAssignment(a)} onClick={() => setSelectedAssignment(a)}
key={a.id} key={a.id}
allowDownload allowDownload
@@ -268,6 +270,7 @@ export default function TeacherDashboard({user}: Props) {
{assignments.filter(archivedFilter).map((a) => ( {assignments.filter(archivedFilter).map((a) => (
<AssignmentCard <AssignmentCard
{...a} {...a}
users={users}
onClick={() => setSelectedAssignment(a)} onClick={() => setSelectedAssignment(a)}
key={a.id} key={a.id}
allowDownload allowDownload

View File

@@ -1,55 +1,191 @@
import BlankQuestionsModal from "@/components/BlankQuestionsModal"; import BlankQuestionsModal from "@/components/BlankQuestionsModal";
import {renderExercise} from "@/components/Exercises"; import { renderExercise } from "@/components/Exercises";
import HighlightContent from "@/components/HighlightContent";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import ModuleTitle from "@/components/Medium/ModuleTitle"; import ModuleTitle from "@/components/Medium/ModuleTitle";
import {renderSolution} from "@/components/Solutions"; import { renderSolution } from "@/components/Solutions";
import {infoButtonStyle} from "@/constants/buttonStyles"; import { infoButtonStyle } from "@/constants/buttonStyles";
import {LevelExam, LevelPart, UserSolution, WritingExam} from "@/interfaces/exam"; import { Module } from "@/interfaces";
import { Exercise, FillBlanksExercise, FillBlanksMCOption, LevelExam, LevelPart, MultipleChoiceExercise, ShuffleMap, UserSolution, WritingExam } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams"; import { defaultUserSolutions } from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils"; import { countExercises } from "@/utils/moduleUtils";
import {mdiArrowRight} from "@mdi/js"; import { mdiArrowRight } from "@mdi/js";
import Icon from "@mdi/react"; import Icon from "@mdi/react";
import clsx from "clsx"; import clsx from "clsx";
import {Fragment, useEffect, useState} from "react"; import { Dispatch, Fragment, SetStateAction, use, useEffect, useMemo, useRef, useState } from "react";
import {BsChevronDown, BsChevronUp} from "react-icons/bs"; import { BsChevronDown, BsChevronUp } from "react-icons/bs";
import {toast} from "react-toastify"; import { toast } from "react-toastify";
interface Props { interface Props {
exam: LevelExam; exam: LevelExam;
showSolutions?: boolean; showSolutions?: boolean;
onFinish: (userSolutions: UserSolution[]) => void; onFinish: (userSolutions: UserSolution[]) => void;
editing?: boolean;
} }
function TextComponent({part}: {part: LevelPart}) { function TextComponent({
part, highlightPhrases, contextWord, setContextWordLine
}: {
part: LevelPart, highlightPhrases: string[], contextWord: string | undefined, setContextWordLine: React.Dispatch<React.SetStateAction<number | undefined>>
}) {
const textRef = useRef<HTMLDivElement>(null);
const [lineNumbers, setLineNumbers] = useState<number[]>([]);
const [lineHeight, setLineHeight] = useState<number>(0);
part.showContextLines = true;
const calculateLineNumbers = () => {
if (textRef.current) {
const computedStyle = window.getComputedStyle(textRef.current);
const lineHeightValue = parseFloat(computedStyle.lineHeight);
const containerWidth = textRef.current.clientWidth;
setLineHeight(lineHeightValue);
const offscreenElement = document.createElement('div');
offscreenElement.style.position = 'absolute';
offscreenElement.style.top = '-9999px';
offscreenElement.style.left = '-9999px';
offscreenElement.style.whiteSpace = 'pre-wrap';
offscreenElement.style.width = `${containerWidth}px`;
offscreenElement.style.font = computedStyle.font;
offscreenElement.style.lineHeight = computedStyle.lineHeight;
offscreenElement.style.wordWrap = 'break-word';
offscreenElement.style.textAlign = computedStyle.textAlign as CanvasTextAlign;
const textContent = textRef.current.textContent || '';
textContent.split(/(\s+)/).forEach((word: string) => {
const span = document.createElement('span');
span.textContent = word;
offscreenElement.appendChild(span);
});
document.body.appendChild(offscreenElement);
const lines: string[][] = [[]];
let currentLine = 1;
let currentLineTop: number | undefined;
let contextWordLine: number | null = null;
const firstChild = offscreenElement.firstChild as HTMLElement;
if (firstChild) {
currentLineTop = firstChild.getBoundingClientRect().top;
}
const spans = offscreenElement.querySelectorAll<HTMLSpanElement>('span');
spans.forEach(span => {
const rect = span.getBoundingClientRect();
const top = rect.top;
if (currentLineTop !== undefined && top > currentLineTop) {
currentLine++;
currentLineTop = top;
lines.push([]);
}
lines[lines.length - 1].push(span.textContent?.trim() || '');
if (contextWord && contextWordLine === null && span.textContent?.includes(contextWord)) {
contextWordLine = currentLine;
}
});
setLineNumbers(lines.map((_, index) => index + 1));
if (contextWordLine) {
setContextWordLine(contextWordLine);
}
document.body.removeChild(offscreenElement);
}
};
useEffect(() => {
calculateLineNumbers();
const resizeObserver = new ResizeObserver(() => {
calculateLineNumbers();
});
if (textRef.current) {
resizeObserver.observe(textRef.current);
}
return () => {
if (textRef.current) {
resizeObserver.unobserve(textRef.current);
}
};
}, [part.context, part.showContextLines, contextWord]);
if (typeof part.showContextLines === "undefined") {
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>
);
}
return ( return (
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" /> <div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
{!!part.context && <div className="flex mt-2">
part.context <div className="flex-shrink-0 w-8 pr-2">
.split(/\n|(\\n)/g) {lineNumbers.map(num => (
.filter((x) => x && x.length > 0 && x !== "\\n") <div key={num} className="text-gray-400 flex justify-end" style={{ lineHeight: `${lineHeight}px` }}>
.map((line, index) => ( {num}
<Fragment key={index}> </div>
<p key={index}>{line}</p>
</Fragment>
))} ))}
</div>
<div ref={textRef} className="h-fit whitespace-pre-wrap ml-2">
<HighlightContent html={part.context!} highlightPhrases={highlightPhrases} firstOccurence={true} />
</div>
</div>
</div> </div>
); );
} }
export default function Level({exam, showSolutions = false, onFinish}: Props) {
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]); const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
return Array.isArray(words) && words.every(
word => word && typeof word === 'object' && 'id' in word && 'options' in word
);
}
export default function Level({ exam, showSolutions = false, onFinish, editing = false }: Props) {
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]);
const [showBlankModal, setShowBlankModal] = useState(false); const [showBlankModal, setShowBlankModal] = useState(false);
const {userSolutions, setUserSolutions} = useExamStore((state) => state); const { userSolutions, setUserSolutions } = useExamStore((state) => state);
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state); const { hasExamEnded, setHasExamEnded } = useExamStore((state) => state);
const {partIndex, setPartIndex} = useExamStore((state) => state); const { partIndex, setPartIndex } = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state); const { exerciseIndex, setExerciseIndex } = useExamStore((state) => state);
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]); const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]);
//const [shuffleMaps, setShuffleMaps] = useExamStore((state) => [state.shuffleMaps, state.setShuffleMaps])
const [currentExercise, setCurrentExercise] = useState<Exercise>();
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
const [highlightPhrases, setContextHighlight] = useState<string[]>([]);
const [contextWord, setContextWord] = useState<string | undefined>(undefined);
const [contextWordLine, setContextWordLine] = useState<number | undefined>(undefined);
/*useEffect(() => {
if (showSolutions && userSolutions[exerciseIndex].shuffleMaps) {
setShuffleMaps(userSolutions[exerciseIndex].shuffleMaps as ShuffleMap[])
}
}, [showSolutions])*/
useEffect(() => { useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) { if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex(exerciseIndex + 1); setExerciseIndex(exerciseIndex + 1);
@@ -65,15 +201,136 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
onFinish(userSolutions); onFinish(userSolutions);
}; };
const getExercise = () => {
if (exerciseIndex === -1) {
return undefined;
}
let exercise = exam.parts[partIndex]?.exercises[exerciseIndex];
if (!exercise) return undefined;
exercise = {
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
};
/*if (exam.shuffle && exercise.type === "multipleChoice") {
if (shuffleMaps.length == 0 && !showSolutions) {
console.log("Shuffling answers");
const newShuffleMaps: ShuffleMap[] = [];
exercise.questions = exercise.questions.map(question => {
const options = [...question.options];
let shuffledOptions = [...options].sort(() => Math.random() - 0.5);
const newOptions = options.map((option, index) => ({
id: option.id,
text: shuffledOptions[index].text
}));
const optionMapping = options.reduce<{ [key: string]: string }>((acc, originalOption) => {
const shuffledPosition = newOptions.find(newOpt => newOpt.text === originalOption.text)?.id;
if (shuffledPosition) {
acc[shuffledPosition] = originalOption.id;
}
return acc;
}, {});
newShuffleMaps.push({ id: question.id, map: optionMapping });
return { ...question, options: newOptions };
});
setShuffleMaps(newShuffleMaps);
} else {
console.log("Retrieving shuffles");
exercise.questions = exercise.questions.map(question => {
const questionShuffleMap = shuffleMaps.find(map => map.id === question.id);
if (questionShuffleMap) {
const newOptions = question.options.map(option => ({
id: option.id,
text: question.options.find(o => questionShuffleMap.map[o.id] === option.id)?.text || option.text
}));
return { ...question, options: newOptions };
}
return question;
});
}
} else if (exam.shuffle && exercise.type === "fillBlanks" && typeCheckWordsMC(exercise.words)) {
if (shuffleMaps.length === 0 && !showSolutions) {
const newShuffleMaps: ShuffleMap[] = [];
exercise.words = exercise.words.map(word => {
if ('options' in word) {
const options = { ...word.options };
const originalKeys = Object.keys(options);
const shuffledKeys = [...originalKeys].sort(() => Math.random() - 0.5);
const newOptions = shuffledKeys.reduce((acc, key, index) => {
acc[key as keyof typeof options] = options[originalKeys[index] as keyof typeof options];
return acc;
}, {} as { [key in keyof typeof options]: string });
const optionMapping = originalKeys.reduce((acc, key, index) => {
acc[key as keyof typeof options] = shuffledKeys[index];
return acc;
}, {} as { [key in keyof typeof options]: string });
newShuffleMaps.push({ id: word.id, map: optionMapping });
return { ...word, options: newOptions };
}
return word;
});
setShuffleMaps(newShuffleMaps);
}
}
*/
return exercise;
};
useEffect(() => {
//console.log("Getting another exercise");
//setShuffleMaps([]);
setCurrentExercise(getExercise());
}, [partIndex, exerciseIndex]);
useEffect(() => {
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
if (currentExercise && currentExercise.type === "multipleChoice") {
const match = currentExercise.questions[storeQuestionIndex].prompt.match(regex);
if (match) {
const word = match[1];
const originalLineNumber = match[2];
setContextHighlight([word]);
if (word !== contextWord) {
setContextWord(word);
}
const updatedPrompt = currentExercise.questions[storeQuestionIndex].prompt.replace(
`in line ${originalLineNumber}`,
`in line ${contextWordLine || originalLineNumber}`
);
currentExercise.questions[storeQuestionIndex].prompt = updatedPrompt;
} else {
setContextHighlight([]);
setContextWord(undefined);
}
}
}, [storeQuestionIndex, contextWordLine, exerciseIndex, partIndex]); //, shuffleMaps]);
const nextExercise = (solution?: UserSolution) => { const nextExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (solution) { if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]);
} }
if (storeQuestionIndex > 0) { if (storeQuestionIndex > 0) {
const exercise = getExercise(); setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: storeQuestionIndex }]);
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: storeQuestionIndex}]);
} }
setStoreQuestionIndex(0); setStoreQuestionIndex(0);
@@ -94,6 +351,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
(x) => x === 0, (x) => x === 0,
) && ) &&
!showSolutions && !showSolutions &&
!editing &&
!hasExamEnded !hasExamEnded
) { ) {
setShowBlankModal(true); setShowBlankModal(true);
@@ -103,7 +361,11 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
setHasExamEnded(false); setHasExamEnded(false);
if (solution) { if (solution) {
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]); let stat = { ...solution, module: "level" as Module, exam: exam.id }
/*if (exam.shuffle) {
stat.shuffleMaps = shuffleMaps
}*/
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...stat }]);
} else { } else {
onFinish(userSolutions); onFinish(userSolutions);
} }
@@ -112,26 +374,17 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
const previousExercise = (solution?: UserSolution) => { const previousExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (solution) { if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]);
} }
if (storeQuestionIndex > 0) { if (storeQuestionIndex > 0) {
const exercise = getExercise(); setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: storeQuestionIndex }]);
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: storeQuestionIndex}]);
} }
setStoreQuestionIndex(0); setStoreQuestionIndex(0);
setExerciseIndex(exerciseIndex - 1); setExerciseIndex(exerciseIndex - 1);
}; };
const getExercise = () => {
const exercise = exam.parts[partIndex].exercises[exerciseIndex];
return {
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
};
};
const calculateExerciseIndex = () => { const calculateExerciseIndex = () => {
if (partIndex === 0) if (partIndex === 0)
return ( return (
@@ -157,7 +410,12 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
</h4> </h4>
<span className="text-base">You will be allowed to read the text while doing the exercises</span> <span className="text-base">You will be allowed to read the text while doing the exercises</span>
</div> </div>
<TextComponent part={exam.parts[partIndex]} /> <TextComponent
part={exam.parts[partIndex]}
highlightPhrases={highlightPhrases}
contextWord={contextWord}
setContextWordLine={setContextWordLine}
/>
</> </>
</div> </div>
); );
@@ -171,7 +429,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
exerciseIndex={calculateExerciseIndex()} exerciseIndex={calculateExerciseIndex()}
module="level" module="level"
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))} totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions} disableTimer={showSolutions || editing}
/> />
<div <div
className={clsx( className={clsx(
@@ -184,12 +442,14 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
partIndex > -1 && partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length && exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions && !showSolutions &&
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)} !editing &&
currentExercise &&
renderExercise(currentExercise, exam.id, nextExercise, previousExercise)}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
partIndex > -1 && partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length && exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions && (showSolutions || editing) &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)} renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
</div> </div>
{exerciseIndex === -1 && partIndex > 0 && ( {exerciseIndex === -1 && partIndex > 0 && (

View File

@@ -1,442 +1,310 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import { useState } from "react"; import {useState} from "react";
import { Module } from "@/interfaces"; import {Module} from "@/interfaces";
import clsx from "clsx"; import clsx from "clsx";
import { User } from "@/interfaces/user"; import {User} from "@/interfaces/user";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import { import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
BsArrowRepeat, import {totalExamsByModule} from "@/utils/stats";
BsBook,
BsCheck,
BsCheckCircle,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
BsXCircle,
} from "react-icons/bs";
import { totalExamsByModule } from "@/utils/stats";
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import { calculateAverageLevel } from "@/utils/score"; import {calculateAverageLevel} from "@/utils/score";
import { sortByModuleName } from "@/utils/moduleUtils"; import {sortByModuleName} from "@/utils/moduleUtils";
import { capitalize } from "lodash"; import {capitalize} from "lodash";
import ProfileSummary from "@/components/ProfileSummary"; import ProfileSummary from "@/components/ProfileSummary";
import { Variant } from "@/interfaces/exam"; import {Variant} from "@/interfaces/exam";
import useSessions, { Session } from "@/hooks/useSessions"; import useSessions, {Session} from "@/hooks/useSessions";
import SessionCard from "@/components/Medium/SessionCard"; import SessionCard from "@/components/Medium/SessionCard";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import moment from "moment"; import moment from "moment";
interface Props { interface Props {
user: User; user: User;
page: "exercises" | "exams"; page: "exercises" | "exams";
onStart: ( onStart: (modules: Module[], avoidRepeated: boolean, variant: Variant) => void;
modules: Module[], disableSelection?: boolean;
avoidRepeated: boolean,
variant: Variant,
) => void;
disableSelection?: boolean;
} }
export default function Selection({ export default function Selection({user, page, onStart, disableSelection = false}: Props) {
user, const [selectedModules, setSelectedModules] = useState<Module[]>([]);
page, const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
onStart, const [variant, setVariant] = useState<Variant>("full");
disableSelection = false,
}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
const [variant, setVariant] = useState<Variant>("full");
const { stats } = useStats(user?.id); const {stats} = useStats(user?.id);
const { sessions, isLoading, reload } = useSessions(user.id); const {sessions, isLoading, reload} = useSessions(user.id);
const state = useExamStore((state) => state); const state = useExamStore((state) => state);
const toggleModule = (module: Module) => { const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module); const modules = selectedModules.filter((x) => x !== module);
setSelectedModules((prev) => setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
prev.includes(module) ? modules : [...modules, module], };
);
};
const loadSession = async (session: Session) => { const loadSession = async (session: Session) => {
state.setSelectedModules(session.selectedModules); state.setSelectedModules(session.selectedModules);
state.setExam(session.exam); state.setExam(session.exam);
state.setExams(session.exams); state.setExams(session.exams);
state.setSessionId(session.sessionId); state.setSessionId(session.sessionId);
state.setAssignment(session.assignment); state.setAssignment(session.assignment);
state.setExerciseIndex(session.exerciseIndex); state.setExerciseIndex(session.exerciseIndex);
state.setPartIndex(session.partIndex); state.setPartIndex(session.partIndex);
state.setModuleIndex(session.moduleIndex); state.setModuleIndex(session.moduleIndex);
state.setTimeSpent(session.timeSpent); state.setTimeSpent(session.timeSpent);
state.setUserSolutions(session.userSolutions); state.setUserSolutions(session.userSolutions);
state.setShowSolutions(false); state.setShowSolutions(false);
state.setQuestionIndex(session.questionIndex); state.setQuestionIndex(session.questionIndex);
}; };
return ( return (
<> <>
<div className="relative flex h-full w-full flex-col gap-8 md:gap-16"> <div className="relative flex h-full w-full flex-col gap-8 md:gap-16">
{user && ( {user && (
<ProfileSummary <ProfileSummary
user={user} user={user}
items={[ items={[
{ {
icon: ( icon: <BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />,
<BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" /> label: "Reading",
), value: totalExamsByModule(stats, "reading"),
label: "Reading", tooltip: "The amount of reading exams performed.",
value: totalExamsByModule(stats, "reading"), },
tooltip: "The amount of reading exams performed.", {
}, icon: <BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />,
{ label: "Listening",
icon: ( value: totalExamsByModule(stats, "listening"),
<BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" /> tooltip: "The amount of listening exams performed.",
), },
label: "Listening", {
value: totalExamsByModule(stats, "listening"), icon: <BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />,
tooltip: "The amount of listening exams performed.", label: "Writing",
}, value: totalExamsByModule(stats, "writing"),
{ tooltip: "The amount of writing exams performed.",
icon: ( },
<BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" /> {
), icon: <BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />,
label: "Writing", label: "Speaking",
value: totalExamsByModule(stats, "writing"), value: totalExamsByModule(stats, "speaking"),
tooltip: "The amount of writing exams performed.", tooltip: "The amount of speaking exams performed.",
}, },
{ {
icon: ( icon: <BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />,
<BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" /> label: "Level",
), value: totalExamsByModule(stats, "level"),
label: "Speaking", tooltip: "The amount of level exams performed.",
value: totalExamsByModule(stats, "speaking"), },
tooltip: "The amount of speaking exams performed.", ]}
}, />
{ )}
icon: (
<BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />
),
label: "Level",
value: totalExamsByModule(stats, "level"),
tooltip: "The amount of level exams performed.",
},
]}
/>
)}
<section className="flex flex-col gap-3"> <section className="flex flex-col gap-3">
<span className="text-lg font-bold">About {capitalize(page)}</span> <span className="text-lg font-bold">About {capitalize(page)}</span>
<span className="text-mti-gray-taupe"> <span className="text-mti-gray-taupe">
{page === "exercises" && ( {page === "exercises" && (
<> <>
In the realm of language acquisition, practice makes perfect, In the realm of language acquisition, practice makes perfect, and our exercises are the key to unlocking your full
and our exercises are the key to unlocking your full potential. potential. Dive into a world of interactive and engaging exercises that cater to diverse learning styles. From grammar
Dive into a world of interactive and engaging exercises that drills that build a strong foundation to vocabulary challenges that broaden your lexicon, our exercises are carefully
cater to diverse learning styles. From grammar drills that build designed to make learning English both enjoyable and effective. Whether you&apos;re looking to reinforce specific
a strong foundation to vocabulary challenges that broaden your skills or embark on a holistic language journey, our exercises are your companions in the pursuit of excellence.
lexicon, our exercises are carefully designed to make learning Embrace the joy of learning as you navigate through a variety of activities that cater to every facet of language
English both enjoyable and effective. Whether you&apos;re acquisition. Your linguistic adventure starts here!
looking to reinforce specific skills or embark on a holistic </>
language journey, our exercises are your companions in the )}
pursuit of excellence. Embrace the joy of learning as you {page === "exams" && (
navigate through a variety of activities that cater to every <>
facet of language acquisition. Your linguistic adventure starts Welcome to the heart of success on your English language journey! Our exams are crafted with precision to assess and
here! enhance your language skills. Each test is a passport to your linguistic prowess, designed to challenge and elevate
</> your abilities. Whether you&apos;re a beginner or a seasoned learner, our exams cater to all levels, providing a
)} comprehensive evaluation of your reading, writing, speaking, and listening skills. Prepare to embark on a journey of
{page === "exams" && ( self-discovery and language mastery as you navigate through our thoughtfully curated exams. Your success is not just a
<> destination; it&apos;s a testament to your dedication and our commitment to empowering you with the English language.
Welcome to the heart of success on your English language </>
journey! Our exams are crafted with precision to assess and )}
enhance your language skills. Each test is a passport to your </span>
linguistic prowess, designed to challenge and elevate your </section>
abilities. Whether you&apos;re a beginner or a seasoned learner,
our exams cater to all levels, providing a comprehensive
evaluation of your reading, writing, speaking, and listening
skills. Prepare to embark on a journey of self-discovery and
language mastery as you navigate through our thoughtfully
curated exams. Your success is not just a destination; it&apos;s
a testament to your dedication and our commitment to empowering
you with the English language.
</>
)}
</span>
</section>
{sessions.length > 0 && ( {sessions.length > 0 && (
<section className="flex flex-col gap-3 md:gap-3"> <section className="flex flex-col gap-3 md:gap-3">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div <div
onClick={reload} onClick={reload}
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out" className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
> <span className="text-mti-black text-lg font-bold">Unfinished Sessions</span>
<span className="text-mti-black text-lg font-bold"> <BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
Unfinished Sessions </div>
</span> </div>
<BsArrowRepeat <span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
className={clsx("text-xl", isLoading && "animate-spin")} {sessions
/> .sort((a, b) => moment(b.date).diff(moment(a.date)))
</div> .map((session) => (
</div> <SessionCard session={session} key={session.sessionId} reload={reload} loadSession={loadSession} />
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll"> ))}
{sessions </span>
.sort((a, b) => moment(b.date).diff(moment(a.date))) </section>
.map((session) => ( )}
<SessionCard
session={session}
key={session.sessionId}
reload={reload}
loadSession={loadSession}
/>
))}
</span>
</section>
)}
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8"> <section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
<div <div
onClick={ onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
!disableSelection && !selectedModules.includes("level") className={clsx(
? () => toggleModule("reading") "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
: undefined selectedModules.includes("reading") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
} )}>
className={clsx( <div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", <BsBook className="h-7 w-7 text-white" />
selectedModules.includes("reading") || disableSelection </div>
? "border-mti-purple-light" <span className="font-semibold">Reading:</span>
: "border-mti-gray-platinum", <p className="text-left text-xs">
)} Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
> </p>
<div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> {!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && (
<BsBook className="h-7 w-7 text-white" /> <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
</div> )}
<span className="font-semibold">Reading:</span> {(selectedModules.includes("reading") || disableSelection) && (
<p className="text-left text-xs"> <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
Expand your vocabulary, improve your reading comprehension and )}
improve your ability to interpret texts in English. {selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
</p> </div>
{!selectedModules.includes("reading") && <div
!selectedModules.includes("level") && onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
!disableSelection && ( className={clsx(
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
)} selectedModules.includes("listening") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
{(selectedModules.includes("reading") || disableSelection) && ( )}>
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> <div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
)} <BsHeadphones className="h-7 w-7 text-white" />
{selectedModules.includes("level") && ( </div>
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" /> <span className="font-semibold">Listening:</span>
)} <p className="text-left text-xs">
</div> Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
<div </p>
onClick={ {!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && (
!disableSelection && !selectedModules.includes("level") <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
? () => toggleModule("listening") )}
: undefined {(selectedModules.includes("listening") || disableSelection) && (
} <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
className={clsx( )}
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", {selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
selectedModules.includes("listening") || disableSelection </div>
? "border-mti-purple-light" <div
: "border-mti-gray-platinum", onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
)} className={clsx(
> "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
<div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> selectedModules.includes("writing") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
<BsHeadphones className="h-7 w-7 text-white" /> )}>
</div> <div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<span className="font-semibold">Listening:</span> <BsPen className="h-7 w-7 text-white" />
<p className="text-left text-xs"> </div>
Improve your ability to follow conversations in English and your <span className="font-semibold">Writing:</span>
ability to understand different accents and intonations. <p className="text-left text-xs">
</p> Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
{!selectedModules.includes("listening") && </p>
!selectedModules.includes("level") && {!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && (
!disableSelection && ( <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> )}
)} {(selectedModules.includes("writing") || disableSelection) && (
{(selectedModules.includes("listening") || disableSelection) && ( <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> )}
)} {selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
{selectedModules.includes("level") && ( </div>
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" /> <div
)} onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
</div> className={clsx(
<div "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
onClick={ selectedModules.includes("speaking") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
!disableSelection && !selectedModules.includes("level") )}>
? () => toggleModule("writing") <div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
: undefined <BsMegaphone className="h-7 w-7 text-white" />
} </div>
className={clsx( <span className="font-semibold">Speaking:</span>
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", <p className="text-left text-xs">
selectedModules.includes("writing") || disableSelection You&apos;ll have access to interactive dialogs, pronunciation exercises and speech recordings.
? "border-mti-purple-light" </p>
: "border-mti-gray-platinum", {!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && (
)} <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
> )}
<div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> {(selectedModules.includes("speaking") || disableSelection) && (
<BsPen className="h-7 w-7 text-white" /> <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
</div> )}
<span className="font-semibold">Writing:</span> {selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
<p className="text-left text-xs"> </div>
Allow you to practice writing in a variety of formats, from simple {!disableSelection && (
paragraphs to complex essays. <div
</p> onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
{!selectedModules.includes("writing") && className={clsx(
!selectedModules.includes("level") && "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
!disableSelection && ( selectedModules.includes("level") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> )}>
)} <div className="bg-ielts-level absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
{(selectedModules.includes("writing") || disableSelection) && ( <BsClipboard className="h-7 w-7 text-white" />
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> </div>
)} <span className="font-semibold">Level:</span>
{selectedModules.includes("level") && ( <p className="text-left text-xs">You&apos;ll be able to test your english level with multiple choice questions.</p>
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" /> {!selectedModules.includes("level") && selectedModules.length === 0 && !disableSelection && (
)} <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
</div> )}
<div {(selectedModules.includes("level") || disableSelection) && (
onClick={ <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
!disableSelection && !selectedModules.includes("level") )}
? () => toggleModule("speaking") {!selectedModules.includes("level") && selectedModules.length > 0 && (
: undefined <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
} )}
className={clsx( </div>
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", )}
selectedModules.includes("speaking") || disableSelection </section>
? "border-mti-purple-light" <div className="-md:flex-col -md:gap-4 -md:justify-center flex w-full items-center md:justify-between">
: "border-mti-gray-platinum", <div className="flex w-full flex-col items-center gap-3">
)} <div
> className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
<div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> onClick={() => setAvoidRepeatedExams((prev) => !prev)}>
<BsMegaphone className="h-7 w-7 text-white" /> <input type="checkbox" className="hidden" />
</div> <div
<span className="font-semibold">Speaking:</span> className={clsx(
<p className="text-left text-xs"> "border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
You&apos;ll have access to interactive dialogs, pronunciation "transition duration-300 ease-in-out",
exercises and speech recordings. avoidRepeatedExams && "!bg-mti-purple-light ",
</p> )}>
{!selectedModules.includes("speaking") && <BsCheck color="white" className="h-full w-full" />
!selectedModules.includes("level") && </div>
!disableSelection && ( <span className="tooltip" data-tip="If possible, the platform will choose exams not yet done.">
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> Avoid Repeated Questions
)} </span>
{(selectedModules.includes("speaking") || disableSelection) && ( </div>
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> <div
)} className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
{selectedModules.includes("level") && ( onClick={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" /> <input type="checkbox" className="hidden" />
)} <div
</div> className={clsx(
{!disableSelection && ( "border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
<div "transition duration-300 ease-in-out",
onClick={ variant === "full" && "!bg-mti-purple-light ",
selectedModules.length === 0 || )}>
selectedModules.includes("level") <BsCheck color="white" className="h-full w-full" />
? () => toggleModule("level") </div>
: undefined <span>Full length exams</span>
} </div>
className={clsx( </div>
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", <div className="tooltip w-full" data-tip={`Your screen size is too small to do ${page}`}>
selectedModules.includes("level") || disableSelection <Button color="purple" className="w-full max-w-xs px-12 md:hidden" disabled>
? "border-mti-purple-light" Start Exam
: "border-mti-gray-platinum", </Button>
)} </div>
> <Button
<div className="bg-ielts-level absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> onClick={() =>
<BsClipboard className="h-7 w-7 text-white" /> onStart(
</div> !disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"],
<span className="font-semibold">Level:</span> avoidRepeatedExams,
<p className="text-left text-xs"> variant,
You&apos;ll be able to test your english level with multiple )
choice questions. }
</p> color="purple"
{!selectedModules.includes("level") && className="-md:hidden w-full max-w-xs px-12 md:self-end"
selectedModules.length === 0 && disabled={selectedModules.length === 0 && !disableSelection}>
!disableSelection && ( Start Exam
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> </Button>
)} </div>
{(selectedModules.includes("level") || disableSelection) && ( </div>
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> </>
)} );
{!selectedModules.includes("level") &&
selectedModules.length > 0 && (
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
)}
</div>
)}
</section>
<div className="-md:flex-col -md:gap-4 -md:justify-center flex w-full items-center md:justify-between">
<div className="flex w-full flex-col items-center gap-3">
<div
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
onClick={() => setAvoidRepeatedExams((prev) => !prev)}
>
<input type="checkbox" className="hidden" />
<div
className={clsx(
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
"transition duration-300 ease-in-out",
avoidRepeatedExams && "!bg-mti-purple-light ",
)}
>
<BsCheck color="white" className="h-full w-full" />
</div>
<span
className="tooltip"
data-tip="If possible, the platform will choose exams not yet done."
>
Avoid Repeated Questions
</span>
</div>
<div
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
// onClick={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
>
<input type="checkbox" className="hidden" disabled />
<div
className={clsx(
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
"transition duration-300 ease-in-out",
variant === "full" && "!bg-mti-purple-light ",
)}
>
<BsCheck color="white" className="h-full w-full" />
</div>
<span>Full length exams</span>
</div>
</div>
<div
className="tooltip w-full"
data-tip={`Your screen size is too small to do ${page}`}
>
<Button
color="purple"
className="w-full max-w-xs px-12 md:hidden"
disabled
>
Start Exam
</Button>
</div>
<Button
onClick={() =>
onStart(
!disableSelection
? selectedModules.sort(sortByModuleName)
: ["reading", "listening", "writing", "speaking"],
avoidRepeatedExams,
variant,
)
}
color="purple"
className="-md:hidden w-full max-w-xs px-12 md:self-end"
disabled={selectedModules.length === 0 && !disableSelection}
>
Start Exam
</Button>
</div>
</div>
</>
);
} }

View File

@@ -2,32 +2,40 @@ import {Group, User} from "@/interfaces/user";
import axios from "axios"; import axios from "axios";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
export default function useGroups(admin?: string, userType?: string) { interface Props {
admin?: string;
userType?: string;
adminAdmins?: string;
}
export default function useGroups({admin, userType, adminAdmins}: Props) {
const [groups, setGroups] = useState<Group[]>([]); const [groups, setGroups] = useState<Group[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
const isMasterType = userType?.startsWith('master'); const isMasterType = userType?.startsWith("master");
const getData = () => { const getData = () => {
setIsLoading(true); setIsLoading(true);
const url = admin ? `/api/groups?admin=${admin}` : "/api/groups"; const url = admin && !adminAdmins ? `/api/groups?admin=${admin}` : "/api/groups";
axios axios
.get<Group[]>(url) .get<Group[]>(url)
.then((response) => { .then((response) => {
if(isMasterType) { if (isMasterType) return setGroups(response.data);
return setGroups(response.data);
}
const filter = (g: Group) => g.admin === admin || g.participants.includes(admin || "");
const filteredGroups = admin ? response.data.filter(filter) : response.data; const filterByAdmins = !!adminAdmins
? [adminAdmins, ...response.data.filter((g) => g.participants.includes(adminAdmins)).flatMap((g) => g.admin)]
: [admin];
const adminFilter = (g: Group) => filterByAdmins.includes(g.admin) || g.participants.includes(admin || "");
const filteredGroups = !!admin || !!adminAdmins ? response.data.filter(adminFilter) : response.data;
return setGroups(admin ? filteredGroups.map((g) => ({...g, disableEditing: g.disableEditing || g.admin !== admin})) : filteredGroups); return setGroups(admin ? filteredGroups.map((g) => ({...g, disableEditing: g.disableEditing || g.admin !== admin})) : filteredGroups);
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
useEffect(getData, [admin, isMasterType]); useEffect(getData, [admin, adminAdmins, isMasterType]);
return {groups, isLoading, isError, reload: getData}; return {groups, isLoading, isError, reload: getData};
} }

View File

@@ -17,7 +17,6 @@ export default function usePermissions(user: string) {
const permissionTypes = response.data const permissionTypes = response.data
.filter((x) => !x.users.includes(user)) .filter((x) => !x.users.includes(user))
.reduce((acc, curr) => [...acc, curr.type], [] as PermissionType[]); .reduce((acc, curr) => [...acc, curr.type], [] as PermissionType[]);
console.log(response.data, permissionTypes);
setPermissions(permissionTypes); setPermissions(permissionTypes);
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));

View File

@@ -12,6 +12,7 @@ interface ExamBase {
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant; variant?: Variant;
difficulty?: Difficulty; difficulty?: Difficulty;
shuffle?: boolean;
createdBy?: string; // option as it has been added later createdBy?: string; // option as it has been added later
createdAt?: string; // option as it has been added later createdAt?: string; // option as it has been added later
} }
@@ -36,6 +37,7 @@ export interface LevelExam extends ExamBase {
export interface LevelPart { export interface LevelPart {
context?: string; context?: string;
showContextLines?: boolean;
exercises: Exercise[]; exercises: Exercise[];
} }
@@ -65,6 +67,7 @@ export interface UserSolution {
}; };
exercise: string; exercise: string;
isDisabled?: boolean; isDisabled?: boolean;
shuffleMaps?: ShuffleMap[]
} }
export interface WritingExam extends ExamBase { export interface WritingExam extends ExamBase {
@@ -77,7 +80,7 @@ interface WordCounter {
limit: number; limit: number;
} }
export interface SpeakingExam extends ExamBase { export interface SpeakingExam extends ExamBase {
module: "speaking"; module: "speaking";
exercises: (SpeakingExercise | InteractiveSpeakingExercise)[]; exercises: (SpeakingExercise | InteractiveSpeakingExercise)[];
instructorGender: InstructorGender; instructorGender: InstructorGender;
@@ -96,8 +99,8 @@ export type Exercise =
export interface Evaluation { export interface Evaluation {
comment: string; comment: string;
overall: number; overall: number;
task_response: {[key: string]: number | {grade: number; comment: string}}; task_response: { [key: string]: number | { grade: number; comment: string } };
misspelled_pairs?: {correction: string | null; misspelled: string}[]; misspelled_pairs?: { correction: string | null; misspelled: string }[];
} }
@@ -110,10 +113,9 @@ type InteractiveTranscriptType = { [key in InteractiveTranscriptKey]?: string };
type InteractiveFixedTextType = { [key in InteractiveFixedTextKey]?: string }; type InteractiveFixedTextType = { [key in InteractiveFixedTextKey]?: string };
interface InteractiveSpeakingEvaluation extends Evaluation, interface InteractiveSpeakingEvaluation extends Evaluation,
InteractivePerfectAnswerType, InteractivePerfectAnswerType,
InteractiveTranscriptType, InteractiveTranscriptType,
InteractiveFixedTextType InteractiveFixedTextType { }
{}
interface SpeakingEvaluation extends CommonEvaluation { interface SpeakingEvaluation extends CommonEvaluation {
@@ -187,10 +189,10 @@ export interface InteractiveSpeakingExercise {
first_title?: string; first_title?: string;
second_title?: string; second_title?: string;
text: string; text: string;
prompts: {text: string; video_url: string}[]; prompts: { text: string; video_url: string }[];
userSolutions: { userSolutions: {
id: string; id: string;
solution: {questionIndex: number; question: string; answer: string}[]; solution: { questionIndex: number; question: string; answer: string }[];
evaluation?: InteractiveSpeakingEvaluation; evaluation?: InteractiveSpeakingEvaluation;
}[]; }[];
topic?: string; topic?: string;
@@ -199,13 +201,23 @@ export interface InteractiveSpeakingExercise {
variant?: "initial" | "final"; variant?: "initial" | "final";
} }
export interface FillBlanksMCOption {
id: string;
options: {
A: string;
B: string;
C: string;
D: string;
}
}
export interface FillBlanksExercise { export interface FillBlanksExercise {
prompt: string; // *EXAMPLE: "Complete the summary below. Click a blank to select the corresponding word for it." prompt: string; // *EXAMPLE: "Complete the summary below. Click a blank to select the corresponding word for it."
type: "fillBlanks"; type: "fillBlanks";
id: string; id: string;
words: (string | {letter: string; word: string})[]; // *EXAMPLE: ["preserve", "unaware"] words: (string | { letter: string; word: string } | FillBlanksMCOption)[]; // *EXAMPLE: ["preserve", "unaware"]
text: string; // *EXAMPLE: "They tried to {{1}} burning" text: string; // *EXAMPLE: "They tried to {{1}} burning"
allowRepetition: boolean; allowRepetition?: boolean;
solutions: { solutions: {
id: string; // *EXAMPLE: "1" id: string; // *EXAMPLE: "1"
solution: string; // *EXAMPLE: "preserve" solution: string; // *EXAMPLE: "preserve"
@@ -214,6 +226,7 @@ export interface FillBlanksExercise {
id: string; // *EXAMPLE: "1" id: string; // *EXAMPLE: "1"
solution: string; // *EXAMPLE: "preserve" solution: string; // *EXAMPLE: "preserve"
}[]; }[];
variant?: string;
} }
export interface TrueFalseExercise { export interface TrueFalseExercise {
@@ -221,7 +234,7 @@ export interface TrueFalseExercise {
id: string; id: string;
prompt: string; // *EXAMPLE: "Select the appropriate option." prompt: string; // *EXAMPLE: "Select the appropriate option."
questions: TrueFalseQuestion[]; questions: TrueFalseQuestion[];
userSolutions: {id: string; solution: "true" | "false" | "not_given"}[]; userSolutions: { id: string; solution: "true" | "false" | "not_given" }[];
} }
export interface TrueFalseQuestion { export interface TrueFalseQuestion {
@@ -250,7 +263,7 @@ export interface MatchSentencesExercise {
type: "matchSentences"; type: "matchSentences";
id: string; id: string;
prompt: string; prompt: string;
userSolutions: {question: string; option: string}[]; userSolutions: { question: string; option: string }[];
sentences: MatchSentenceExerciseSentence[]; sentences: MatchSentenceExerciseSentence[];
allowRepetition: boolean; allowRepetition: boolean;
options: MatchSentenceExerciseOption[]; options: MatchSentenceExerciseOption[];
@@ -273,7 +286,7 @@ export interface MultipleChoiceExercise {
id: string; id: string;
prompt: string; // *EXAMPLE: "Select the appropriate option." prompt: string; // *EXAMPLE: "Select the appropriate option."
questions: MultipleChoiceQuestion[]; questions: MultipleChoiceQuestion[];
userSolutions: {question: string; option: string}[]; userSolutions: { question: string; option: string }[];
} }
export interface MultipleChoiceQuestion { export interface MultipleChoiceQuestion {
@@ -286,4 +299,12 @@ export interface MultipleChoiceQuestion {
src?: string; // *EXAMPLE: "https://i.imgur.com/rEbrSqA.png" (only used if the variant is "image") src?: string; // *EXAMPLE: "https://i.imgur.com/rEbrSqA.png" (only used if the variant is "image")
text?: string; // *EXAMPLE: "wallet, pens and novel" (only used if the variant is "text") text?: string; // *EXAMPLE: "wallet, pens and novel" (only used if the variant is "text")
}[]; }[];
shuffleMap?: Record<string, string>;
}
export interface ShuffleMap {
id: string;
map: {
[key: string]: string;
}
} }

View File

@@ -1,189 +1,162 @@
import { Module } from "."; import {Module} from ".";
import { InstructorGender } from "./exam"; import {InstructorGender, ShuffleMap} from "./exam";
import { PermissionType } from "./permissions"; import {PermissionType} from "./permissions";
export type User = export type User = StudentUser | TeacherUser | CorporateUser | AgentUser | AdminUser | DeveloperUser | MasterCorporateUser;
| StudentUser
| TeacherUser
| CorporateUser
| AgentUser
| AdminUser
| DeveloperUser
| MasterCorporateUser;
export type UserStatus = "active" | "disabled" | "paymentDue"; export type UserStatus = "active" | "disabled" | "paymentDue";
export interface BasicUser { export interface BasicUser {
email: string; email: string;
name: string; name: string;
profilePicture: string; profilePicture: string;
id: string; id: string;
isFirstLogin: boolean; isFirstLogin: boolean;
focus: "academic" | "general"; focus: "academic" | "general";
levels: { [key in Module]: number }; levels: {[key in Module]: number};
desiredLevels: { [key in Module]: number }; desiredLevels: {[key in Module]: number};
type: Type; type: Type;
bio: string; bio: string;
isVerified: boolean; isVerified: boolean;
subscriptionExpirationDate?: null | Date; subscriptionExpirationDate?: null | Date;
registrationDate?: Date; registrationDate?: Date;
status: UserStatus; status: UserStatus;
permissions: PermissionType[], permissions: PermissionType[];
lastLogin?: Date;
} }
export interface StudentUser extends BasicUser { export interface StudentUser extends BasicUser {
type: "student"; type: "student";
preferredGender?: InstructorGender; preferredGender?: InstructorGender;
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
preferredTopics?: string[]; preferredTopics?: string[];
} }
export interface TeacherUser extends BasicUser { export interface TeacherUser extends BasicUser {
type: "teacher"; type: "teacher";
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
} }
export interface CorporateUser extends BasicUser { export interface CorporateUser extends BasicUser {
type: "corporate"; type: "corporate";
corporateInformation: CorporateInformation; corporateInformation: CorporateInformation;
demographicInformation?: DemographicCorporateInformation; demographicInformation?: DemographicCorporateInformation;
} }
export interface MasterCorporateUser extends BasicUser { export interface MasterCorporateUser extends BasicUser {
type: "mastercorporate"; type: "mastercorporate";
corporateInformation: CorporateInformation; corporateInformation: CorporateInformation;
demographicInformation?: DemographicCorporateInformation; demographicInformation?: DemographicCorporateInformation;
} }
export interface AgentUser extends BasicUser { export interface AgentUser extends BasicUser {
type: "agent"; type: "agent";
agentInformation: AgentInformation; agentInformation: AgentInformation;
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
} }
export interface AdminUser extends BasicUser { export interface AdminUser extends BasicUser {
type: "admin"; type: "admin";
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
} }
export interface DeveloperUser extends BasicUser { export interface DeveloperUser extends BasicUser {
type: "developer"; type: "developer";
preferredGender?: InstructorGender; preferredGender?: InstructorGender;
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
preferredTopics?: string[]; preferredTopics?: string[];
} }
export interface CorporateInformation { export interface CorporateInformation {
companyInformation: CompanyInformation; companyInformation: CompanyInformation;
monthlyDuration: number; monthlyDuration: number;
payment?: { payment?: {
value: number; value: number;
currency: string; currency: string;
commission: number; commission: number;
}; };
referralAgent?: string; referralAgent?: string;
} }
export interface AgentInformation { export interface AgentInformation {
companyName: string; companyName: string;
commercialRegistration: string; commercialRegistration: string;
companyArabName?: string; companyArabName?: string;
} }
export interface CompanyInformation { export interface CompanyInformation {
name: string; name: string;
userAmount: number; userAmount: number;
} }
export interface DemographicInformation { export interface DemographicInformation {
country: string; country: string;
phone: string; phone: string;
gender: Gender; gender: Gender;
employment: EmploymentStatus; employment: EmploymentStatus;
passport_id?: string; passport_id?: string;
timezone?: string; timezone?: string;
} }
export interface DemographicCorporateInformation { export interface DemographicCorporateInformation {
country: string; country: string;
phone: string; phone: string;
gender: Gender; gender: Gender;
position: string; position: string;
timezone?: string; timezone?: string;
} }
export type Gender = "male" | "female" | "other"; export type Gender = "male" | "female" | "other";
export type EmploymentStatus = export type EmploymentStatus = "employed" | "student" | "self-employed" | "unemployed" | "retired" | "other";
| "employed" export const EMPLOYMENT_STATUS: {status: EmploymentStatus; label: string}[] = [
| "student" {status: "student", label: "Student"},
| "self-employed" {status: "employed", label: "Employed"},
| "unemployed" {status: "unemployed", label: "Unemployed"},
| "retired" {status: "self-employed", label: "Self-employed"},
| "other"; {status: "retired", label: "Retired"},
export const EMPLOYMENT_STATUS: { status: EmploymentStatus; label: string }[] = {status: "other", label: "Other"},
[ ];
{ status: "student", label: "Student" },
{ status: "employed", label: "Employed" },
{ status: "unemployed", label: "Unemployed" },
{ status: "self-employed", label: "Self-employed" },
{ status: "retired", label: "Retired" },
{ status: "other", label: "Other" },
];
export interface Stat { export interface Stat {
id: string; id: string;
user: string; user: string;
exam: string; exam: string;
exercise: string; exercise: string;
session: string; session: string;
date: number; date: number;
module: Module; module: Module;
solutions: any[]; solutions: any[];
type: string; type: string;
timeSpent?: number; timeSpent?: number;
inactivity?: number; inactivity?: number;
assignment?: string; assignment?: string;
score: { score: {
correct: number; correct: number;
total: number; total: number;
missing: number; missing: number;
}; };
isDisabled?: boolean; isDisabled?: boolean;
shuffleMaps?: ShuffleMap[];
} }
export interface Group { export interface Group {
admin: string; admin: string;
name: string; name: string;
participants: string[]; participants: string[];
id: string; id: string;
disableEditing?: boolean; disableEditing?: boolean;
} }
export interface Code { export interface Code {
code: string; code: string;
creator: string; creator: string;
expiryDate: Date; expiryDate: Date;
type: Type; type: Type;
creationDate?: string; creationDate?: string;
userId?: string; userId?: string;
email?: string; email?: string;
name?: string; name?: string;
passport_id?: string; passport_id?: string;
} }
export type Type = export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent" | "mastercorporate";
| "student" export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent", "mastercorporate"];
| "teacher"
| "corporate"
| "admin"
| "developer"
| "agent"
| "mastercorporate";
export const userTypes: Type[] = [
"student",
"teacher",
"corporate",
"admin",
"developer",
"agent",
"mastercorporate",
];

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -111,7 +111,6 @@ export default function BatchCreateUser({user}: {user: User}) {
return clear(); return clear();
} }
console.log(information);
setInfos(information); setInfos(information);
} catch { } catch {
toast.error( toast.error(

View File

@@ -154,7 +154,13 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`, label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
}))} }))}
options={users options={users
.filter((x) => (user.type === "teacher" ? x.type === "student" : x.type === "student" || x.type === "teacher")) .filter((x) =>
user.type === "teacher"
? x.type === "student"
: user.type === "corporate"
? x.type === "student" || x.type === "teacher"
: x.type === "student" || x.type === "teacher" || x.type === "corporate",
)
.map((x) => ({value: x.id, label: `${x.email} - ${x.name}`}))} .map((x) => ({value: x.id, label: `${x.email} - ${x.name}`}))}
onChange={(value) => setParticipants(value.map((x) => x.value))} onChange={(value) => setParticipants(value.map((x) => x.value))}
isMulti isMulti
@@ -201,7 +207,11 @@ export default function GroupList({user}: {user: User}) {
const {permissions} = usePermissions(user?.id || ""); const {permissions} = usePermissions(user?.id || "");
const {users} = useUsers(); const {users} = useUsers();
const {groups, reload} = useGroups(user && filterTypes.includes(user?.type) ? user.id : undefined, user?.type); const {groups, reload} = useGroups({
admin: user && filterTypes.includes(user?.type) ? user.id : undefined,
userType: user?.type,
adminAdmins: user?.type === "teacher" ? user?.id : undefined,
});
useEffect(() => { useEffect(() => {
if (user && ["corporate", "teacher", "mastercorporate"].includes(user.type)) { if (user && ["corporate", "teacher", "mastercorporate"].includes(user.type)) {

View File

@@ -58,7 +58,7 @@ export default function UserList({
const {users, reload} = useUsers(); const {users, reload} = useUsers();
const {permissions} = usePermissions(user?.id || ""); const {permissions} = usePermissions(user?.id || "");
const {groups} = useGroups(user && ["corporate", "teacher", "mastercorporate"].includes(user?.type) ? user.id : undefined); const {groups} = useGroups({admin: user && ["corporate", "teacher", "mastercorporate"].includes(user?.type) ? user.id : undefined});
const appendUserFilters = useFilterStore((state) => state.appendUserFilter); const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter(); const router = useRouter();
@@ -262,10 +262,12 @@ export default function UserList({
cell: ({row, getValue}) => ( cell: ({row, getValue}) => (
<div <div
className={clsx( className={clsx(
PERMISSIONS.updateExpiryDate[row.original.type]?.includes(user.type) && checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer", "underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
)} )}
onClick={() => (PERMISSIONS.updateExpiryDate[row.original.type]?.includes(user.type) ? setSelectedUser(row.original) : null)}> onClick={() =>
checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) ? setSelectedUser(row.original) : null
}>
{getValue()} {getValue()}
</div> </div>
), ),
@@ -279,10 +281,10 @@ export default function UserList({
) as any, ) as any,
cell: (info) => cell: (info) =>
info.getValue() info.getValue()
? `${countryCodes.findOne("countryCode" as any, info.getValue()).flag} ${ ? `${countryCodes.findOne("countryCode" as any, info.getValue())?.flag} ${
countries[info.getValue() as unknown as keyof TCountries].name countries[info.getValue() as unknown as keyof TCountries]?.name
} (+${countryCodes.findOne("countryCode" as any, info.getValue()).countryCallingCode})` } (+${countryCodes.findOne("countryCode" as any, info.getValue())?.countryCallingCode})`
: "Not available", : "N/A",
}), }),
columnHelper.accessor("demographicInformation.phone", { columnHelper.accessor("demographicInformation.phone", {
header: ( header: (
@@ -291,7 +293,7 @@ export default function UserList({
<SorterArrow name="phone" /> <SorterArrow name="phone" />
</button> </button>
) as any, ) as any,
cell: (info) => info.getValue() || "Not available", cell: (info) => info.getValue() || "N/A",
enableSorting: true, enableSorting: true,
}), }),
columnHelper.accessor( columnHelper.accessor(
@@ -301,14 +303,23 @@ export default function UserList({
id: "employment", id: "employment",
header: ( header: (
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "employment"))}> <button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "employment"))}>
<span>Employment/Position</span> <span>Employment</span>
<SorterArrow name="employment" /> <SorterArrow name="employment" />
</button> </button>
) as any, ) as any,
cell: (info) => (info.row.original.type === "corporate" ? info.getValue() : capitalize(info.getValue())) || "Not available", cell: (info) => (info.row.original.type === "corporate" ? info.getValue() : capitalize(info.getValue())) || "N/A",
enableSorting: true, enableSorting: true,
}, },
), ),
columnHelper.accessor("lastLogin", {
header: (
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "lastLogin"))}>
<span>Last Login</span>
<SorterArrow name="lastLogin" />
</button>
) as any,
cell: (info) => (!!info.getValue() ? moment(info.getValue()).format("YYYY-MM-DD HH:mm") : "N/A"),
}),
columnHelper.accessor("demographicInformation.gender", { columnHelper.accessor("demographicInformation.gender", {
header: ( header: (
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "gender"))}> <button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "gender"))}>
@@ -316,7 +327,7 @@ export default function UserList({
<SorterArrow name="gender" /> <SorterArrow name="gender" />
</button> </button>
) as any, ) as any,
cell: (info) => capitalize(info.getValue()) || "Not available", cell: (info) => capitalize(info.getValue()) || "N/A",
enableSorting: true, enableSorting: true,
}), }),
{ {
@@ -341,11 +352,13 @@ export default function UserList({
cell: ({row, getValue}) => ( cell: ({row, getValue}) => (
<div <div
className={clsx( className={clsx(
PERMISSIONS.updateExpiryDate[row.original.type]?.includes(user.type) && checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer", "underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
)} )}
onClick={() => (PERMISSIONS.updateExpiryDate[row.original.type]?.includes(user.type) ? setSelectedUser(row.original) : null)}> onClick={() =>
{row.original.type === "corporate" ? row.original.corporateInformation?.companyInformation?.name || getValue() : getValue()} checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) ? setSelectedUser(row.original) : null
}>
{getValue()}
</div> </div>
), ),
}), }),
@@ -379,7 +392,7 @@ export default function UserList({
columnHelper.accessor("corporateInformation.companyInformation.name", { columnHelper.accessor("corporateInformation.companyInformation.name", {
header: ( header: (
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "companyName"))}> <button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "companyName"))}>
<span>Company Name</span> <span>Company</span>
<SorterArrow name="companyName" /> <SorterArrow name="companyName" />
</button> </button>
) as any, ) as any,
@@ -388,7 +401,7 @@ export default function UserList({
columnHelper.accessor("subscriptionExpirationDate", { columnHelper.accessor("subscriptionExpirationDate", {
header: ( header: (
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "expiryDate"))}> <button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "expiryDate"))}>
<span>Expiry Date</span> <span>Expiration</span>
<SorterArrow name="expiryDate" /> <SorterArrow name="expiryDate" />
</button> </button>
) as any, ) as any,
@@ -401,7 +414,7 @@ export default function UserList({
columnHelper.accessor("isVerified", { columnHelper.accessor("isVerified", {
header: ( header: (
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "verification"))}> <button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "verification"))}>
<span>Verification</span> <span>Verified</span>
<SorterArrow name="verification" /> <SorterArrow name="verification" />
</button> </button>
) as any, ) as any,
@@ -464,6 +477,15 @@ export default function UserList({
return 0; return 0;
} }
if (sorter === "lastLogin" || sorter === reverseString("lastLogin")) {
if (!a.lastLogin && b.lastLogin) return sorter === "lastLogin" ? -1 : 1;
if (a.lastLogin && !b.lastLogin) return sorter === "lastLogin" ? 1 : -1;
if (!a.lastLogin && !b.lastLogin) return 0;
if (moment(a.lastLogin).isAfter(b.lastLogin)) return sorter === "lastLogin" ? -1 : 1;
if (moment(b.lastLogin).isAfter(a.lastLogin)) return sorter === "lastLogin" ? 1 : -1;
return 0;
}
if (sorter === "country" || sorter === reverseString("country")) { if (sorter === "country" || sorter === reverseString("country")) {
if (!a.demographicInformation?.country && b.demographicInformation?.country) return sorter === "country" ? -1 : 1; if (!a.demographicInformation?.country && b.demographicInformation?.country) return sorter === "country" ? -1 : 1;
if (a.demographicInformation?.country && !b.demographicInformation?.country) return sorter === "country" ? 1 : -1; if (a.demographicInformation?.country && !b.demographicInformation?.country) return sorter === "country" ? 1 : -1;

View File

@@ -27,7 +27,7 @@ export default function Lists({user}: {user: User}) {
}> }>
User List User List
</Tab> </Tab>
{checkAccess(user, ["developer"]) && ( {checkAccess(user, ["developer", "admin", "corporate", "mastercorporate", "teacher"]) && (
<Tab <Tab
className={({selected}) => className={({selected}) =>
clsx( clsx(

View File

@@ -12,7 +12,7 @@ import Selection from "@/exams/Selection";
import Speaking from "@/exams/Speaking"; import Speaking from "@/exams/Speaking";
import Writing from "@/exams/Writing"; import Writing from "@/exams/Writing";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import {Exam, UserSolution, Variant} from "@/interfaces/exam"; import {Exam, LevelExam, UserSolution, Variant} from "@/interfaces/exam";
import {Stat} from "@/interfaces/user"; import {Stat} from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {evaluateSpeakingAnswer, evaluateWritingAnswer} from "@/utils/evaluation"; import {evaluateSpeakingAnswer, evaluateWritingAnswer} from "@/utils/evaluation";
@@ -257,6 +257,7 @@ export default function ExamPage({page}: Props) {
user: user?.id || "", user: user?.id || "",
date: new Date().getTime(), date: new Date().getTime(),
isDisabled: solution.isDisabled, isDisabled: solution.isDisabled,
shuffleMaps: solution.shuffleMaps,
...(assignment ? {assignment: assignment.id} : {}), ...(assignment ? {assignment: assignment.id} : {}),
})); }));
@@ -459,6 +460,19 @@ export default function ExamPage({page}: Props) {
inactivity: totalInactivity, inactivity: totalInactivity,
}} }}
onViewResults={(index?: number) => { onViewResults={(index?: number) => {
if (exams[0].module === "level") {
const levelExam = exams[0] as LevelExam;
const allExercises = levelExam.parts.flatMap(part => part.exercises);
const exerciseOrderMap = new Map(allExercises.map((ex, index) => [ex.id, index]));
const orderedSolutions = userSolutions.slice().sort((a, b) => {
const indexA = exerciseOrderMap.get(a.exercise) ?? Infinity;
const indexB = exerciseOrderMap.get(b.exercise) ?? Infinity;
return indexA - indexB;
});
setUserSolutions(orderedSolutions);
} else {
setUserSolutions(userSolutions);
}
setShowSolutions(true); setShowSolutions(true);
setModuleIndex(index || 0); setModuleIndex(index || 0);
setExerciseIndex(["reading", "listening"].includes(exams[0].module) ? -1 : 0); setExerciseIndex(["reading", "listening"].includes(exams[0].module) ? -1 : 0);

View File

@@ -31,7 +31,7 @@ export default function PaymentDue({user, hasExpired = false, reload}: Props) {
const {packages} = usePackages(); const {packages} = usePackages();
const {discounts} = useDiscounts(); const {discounts} = useDiscounts();
const {users} = useUsers(); const {users} = useUsers();
const {groups} = useGroups(); const {groups} = useGroups({});
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user?.id}); const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user?.id});
useEffect(() => { useEffect(() => {

View File

@@ -36,5 +36,5 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
const assigners = await getAllAssignersByCorporate(id); const assigners = await getAllAssignersByCorporate(id);
const assignments = await getAssignmentsByAssigners([...assigners, id]); const assignments = await getAssignmentsByAssigners([...assigners, id]);
res.status(200).json(assignments); res.status(200).json(uniqBy(assignments, "id"));
} }

View File

@@ -1,174 +1,160 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import { app } from "@/firebase"; import {app} from "@/firebase";
import { import {getFirestore, setDoc, doc, query, collection, where, getDocs, getDoc, deleteDoc} from "firebase/firestore";
getFirestore, import {withIronSessionApiRoute} from "iron-session/next";
setDoc, import {sessionOptions} from "@/lib/session";
doc, import {Code, Group, Type} from "@/interfaces/user";
query, import {PERMISSIONS} from "@/constants/userPermissions";
collection, import {uuidv4} from "@firebase/util";
where, import {prepareMailer, prepareMailOptions} from "@/email";
getDocs,
getDoc,
deleteDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Code, Type } from "@/interfaces/user";
import { PERMISSIONS } from "@/constants/userPermissions";
import { uuidv4 } from "@firebase/util";
import { prepareMailer, prepareMailOptions } from "@/email";
const db = getFirestore(app); const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res); if (req.method === "GET") return get(req, res);
if (req.method === "POST") return post(req, res); if (req.method === "POST") return post(req, res);
if (req.method === "DELETE") return del(req, res); if (req.method === "DELETE") return del(req, res);
return res.status(404).json({ ok: false }); return res.status(404).json({ok: false});
} }
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res res.status(401).json({ok: false, reason: "You must be logged in to generate a code!"});
.status(401) return;
.json({ ok: false, reason: "You must be logged in to generate a code!" }); }
return;
}
const { creator } = req.query as { creator?: string }; const {creator} = req.query as {creator?: string};
const q = query( const q = query(collection(db, "codes"), where("creator", "==", creator || ""));
collection(db, "codes"), const snapshot = await getDocs(creator ? q : collection(db, "codes"));
where("creator", "==", creator || ""),
);
const snapshot = await getDocs(creator ? q : collection(db, "codes"));
res.status(200).json(snapshot.docs.map((doc) => doc.data())); res.status(200).json(snapshot.docs.map((doc) => doc.data()));
} }
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res res.status(401).json({ok: false, reason: "You must be logged in to generate a code!"});
.status(401) return;
.json({ ok: false, reason: "You must be logged in to generate a code!" }); }
return;
}
const { type, codes, infos, expiryDate } = req.body as { const {type, codes, infos, expiryDate} = req.body as {
type: Type; type: Type;
codes: string[]; codes: string[];
infos?: { email: string; name: string; passport_id?: string }[]; infos?: {email: string; name: string; passport_id?: string}[];
expiryDate: null | Date; expiryDate: null | Date;
}; };
const permission = PERMISSIONS.generateCode[type]; const permission = PERMISSIONS.generateCode[type];
if (!permission.includes(req.session.user.type)) { if (!permission.includes(req.session.user.type)) {
res.status(403).json({ res.status(403).json({
ok: false, ok: false,
reason: reason: "Your account type does not have permissions to generate a code for that type of user!",
"Your account type does not have permissions to generate a code for that type of user!", });
}); return;
return; }
}
const codesGeneratedByUserSnapshot = await getDocs( const codesGeneratedByUserSnapshot = await getDocs(query(collection(db, "codes"), where("creator", "==", req.session.user.id)));
query(collection(db, "codes"), where("creator", "==", req.session.user.id)), const creatorGroupsSnapshot = await getDocs(query(collection(db, "groups"), where("admin", "==", req.session.user.id)));
);
const userCodes = codesGeneratedByUserSnapshot.docs.map((x) => ({
...x.data(),
}));
if (req.session.user.type === "corporate") { const creatorGroups = (
const totalCodes = codesGeneratedByUserSnapshot.docs.length + codes.length; creatorGroupsSnapshot.docs.map((x) => ({
const allowedCodes = ...x.data(),
req.session.user.corporateInformation?.companyInformation.userAmount || 0; })) as Group[]
).filter((x) => x.name === "Students" || x.name === "Teachers" || x.name === "Corporate");
if (totalCodes > allowedCodes) { const usersInGroups = creatorGroups.flatMap((x) => x.participants);
res.status(403).json({ const userCodes = codesGeneratedByUserSnapshot.docs.map((x) => ({
ok: false, ...x.data(),
reason: `You have or would have exceeded your amount of allowed codes, you currently are allowed to generate ${ })) as Code[];
allowedCodes - codesGeneratedByUserSnapshot.docs.length
} codes.`,
});
return;
}
}
const codePromises = codes.map(async (code, index) => { if (req.session.user.type === "corporate") {
const codeRef = doc(db, "codes", code); const totalCodes = userCodes.filter((x) => !x.userId || !usersInGroups.includes(x.userId)).length + usersInGroups.length + codes.length;
let codeInformation = { const allowedCodes = req.session.user.corporateInformation?.companyInformation.userAmount || 0;
type,
code,
creator: req.session.user!.id,
creationDate: new Date().toISOString(),
expiryDate,
};
if (infos && infos.length > index) { if (totalCodes > allowedCodes) {
const { email, name, passport_id } = infos[index]; res.status(403).json({
const previousCode = userCodes.find((x) => x.email === email) as Code; ok: false,
reason: `You have or would have exceeded your amount of allowed codes, you currently are allowed to generate ${
allowedCodes - codesGeneratedByUserSnapshot.docs.length
} codes.`,
});
return;
}
}
const transport = prepareMailer(); const codePromises = codes.map(async (code, index) => {
const mailOptions = prepareMailOptions( const codeRef = doc(db, "codes", code);
{ let codeInformation = {
type, type,
code: previousCode ? previousCode.code : code, code,
environment: process.env.ENVIRONMENT, creator: req.session.user!.id,
}, creationDate: new Date().toISOString(),
[email.toLowerCase().trim()], expiryDate,
"EnCoach Registration", };
"main",
);
try { if (infos && infos.length > index) {
await transport.sendMail(mailOptions); const {email, name, passport_id} = infos[index];
const previousCode = userCodes.find((x) => x.email === email) as Code;
if (!previousCode) { const transport = prepareMailer();
await setDoc( const mailOptions = prepareMailOptions(
codeRef, {
{ type,
...codeInformation, code: previousCode ? previousCode.code : code,
email: email.trim().toLowerCase(), environment: process.env.ENVIRONMENT,
name: name.trim(), },
...(passport_id ? { passport_id: passport_id.trim() } : {}), [email.toLowerCase().trim()],
}, "EnCoach Registration",
{ merge: true }, "main",
); );
}
return true; try {
} catch (e) { await transport.sendMail(mailOptions);
return false;
}
} else {
await setDoc(codeRef, codeInformation);
}
});
Promise.all(codePromises).then((results) => { if (!previousCode) {
res.status(200).json({ ok: true, valid: results.filter((x) => x).length }); await setDoc(
}); codeRef,
{
...codeInformation,
email: email.trim().toLowerCase(),
name: name.trim(),
...(passport_id ? {passport_id: passport_id.trim()} : {}),
},
{merge: true},
);
}
return true;
} catch (e) {
return false;
}
} else {
await setDoc(codeRef, codeInformation);
}
});
Promise.all(codePromises).then((results) => {
res.status(200).json({ok: true, valid: results.filter((x) => x).length});
});
} }
async function del(req: NextApiRequest, res: NextApiResponse) { async function del(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res res.status(401).json({ok: false, reason: "You must be logged in to generate a code!"});
.status(401) return;
.json({ ok: false, reason: "You must be logged in to generate a code!" }); }
return;
}
const codes = req.query.code as string[]; const codes = req.query.code as string[];
for (const code of codes) { for (const code of codes) {
const snapshot = await getDoc(doc(db, "codes", code as string)); const snapshot = await getDoc(doc(db, "codes", code as string));
if (!snapshot.exists()) continue; if (!snapshot.exists()) continue;
await deleteDoc(snapshot.ref); await deleteDoc(snapshot.ref);
} }
res.status(200).json({ codes }); res.status(200).json({codes});
} }

View File

@@ -107,10 +107,12 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
} }
const user = docUser.data() as User; const user = docUser.data() as User;
await setDoc(docUser.ref, {lastLogin: new Date().toISOString()}, {merge: true});
req.session.user = { req.session.user = {
...user, ...user,
id: req.session.user.id, id: req.session.user.id,
lastLogin: new Date(),
}; };
await req.session.save(); await req.session.save();

119
src/pages/groups.tsx Normal file
View File

@@ -0,0 +1,119 @@
/* 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 useStats from "@/hooks/useStats";
import {averageScore, groupBySession, totalExams} from "@/utils/stats";
import useUser from "@/hooks/useUser";
import Diagnostic from "@/components/Diagnostic";
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 axios from "axios";
import DemographicInformationInput from "@/components/DemographicInformationInput";
import moment from "moment";
import Link from "next/link";
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, MasterCorporateUser, Type, User, userTypes} from "@/interfaces/user";
import Select from "react-select";
import {USER_TYPE_LABELS} from "@/resources/user";
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import useGroups from "@/hooks/useGroups";
import useUsers from "@/hooks/useUsers";
import {getUserName} from "@/utils/users";
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
if (!user || !user.isVerified) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (shouldRedirectHome(user)) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: {user: req.session.user},
};
}, sessionOptions);
interface Props {
user: User;
envVariables: {[key: string]: string};
}
export default function Home(props: Props) {
const {user, mutateUser} = useUser({redirectTo: "/login"});
const {groups} = useGroups({});
const {users} = useUsers();
const router = useRouter();
useEffect(() => {
console.log(groups);
}, [groups]);
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="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{groups
.filter((x) => x.participants.includes(user.id))
.map((group) => (
<div key={group.id} className="p-4 border rounded-xl flex flex-col gap-2">
<span>
<b>Group: </b>
{group.name}
</span>
<span>
<b>Admin: </b>
{getUserName(users.find((x) => x.id === group.admin))}
</span>
<b>Participants: </b>
<span>{group.participants.map((x) => getUserName(users.find((u) => u.id === x))).join(", ")}</span>
</div>
))}
</div>
</Layout>
)}
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +1,29 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Head from "next/head"; import Head from "next/head";
import { withIronSessionSsr } from "iron-session/next"; import {withIronSessionSsr} from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import { Stat, User } from "@/interfaces/user"; import {Stat, User} from "@/interfaces/user";
import { useEffect, useRef, useState } from "react"; import {useEffect, useRef, useState} from "react";
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import { groupByDate } from "@/utils/stats"; import {groupByDate} from "@/utils/stats";
import moment from "moment"; import moment from "moment";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import { ToastContainer } from "react-toastify"; import {ToastContainer} from "react-toastify";
import { useRouter } from "next/router"; import {useRouter} from "next/router";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import clsx from "clsx"; import clsx from "clsx";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import {shouldRedirectHome} from "@/utils/navigation.disabled";
import useAssignments from "@/hooks/useAssignments"; import useAssignments from "@/hooks/useAssignments";
import { uuidv4 } from "@firebase/util"; import {uuidv4} from "@firebase/util";
import { usePDFDownload } from "@/hooks/usePDFDownload"; import {usePDFDownload} from "@/hooks/usePDFDownload";
import useRecordStore from "@/stores/recordStore"; import useRecordStore from "@/stores/recordStore";
import useTrainingContentStore from "@/stores/trainingContentStore"; import useTrainingContentStore from "@/stores/trainingContentStore";
import StatsGridItem from "@/components/StatGridItem"; import StatsGridItem from "@/components/StatGridItem";
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
const user = req.session.user; const user = req.session.user;
if (!user || !user.isVerified) { if (!user || !user.isVerified) {
@@ -46,7 +45,7 @@ export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
} }
return { return {
props: { user: req.session.user }, props: {user: req.session.user},
}; };
}, sessionOptions); }, sessionOptions);
@@ -55,16 +54,21 @@ const defaultSelectableCorporate = {
label: "All", label: "All",
}; };
export default function History({ user }: { user: User }) { export default function History({user}: {user: User}) {
const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [state.selectedUser, state.setSelectedUser, state.training, state.setTraining]); const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [
state.selectedUser,
state.setSelectedUser,
state.training,
state.setTraining,
]);
// const [statsUserId, setStatsUserId] = useState<string | undefined>(user.id); // const [statsUserId, setStatsUserId] = useState<string | undefined>(user.id);
const [groupedStats, setGroupedStats] = useState<{ [key: string]: Stat[] }>(); const [groupedStats, setGroupedStats] = useState<{[key: string]: Stat[]}>();
const [filter, setFilter] = useState<"months" | "weeks" | "days" | "assignments">(); const [filter, setFilter] = useState<"months" | "weeks" | "days" | "assignments">();
const { assignments } = useAssignments({}); const {assignments} = useAssignments({});
const { users } = useUsers(); const {users} = useUsers();
const { stats, isLoading: isStatsLoading } = useStats(statsUserId); const {stats, isLoading: isStatsLoading} = useStats(statsUserId);
const { groups: allGroups } = useGroups(); const {groups: allGroups} = useGroups({});
const groups = allGroups.filter((x) => x.admin === user.id); const groups = allGroups.filter((x) => x.admin === user.id);
@@ -104,12 +108,12 @@ export default function History({ user }: { user: User }) {
setFilter((prev) => (prev === value ? undefined : value)); setFilter((prev) => (prev === value ? undefined : value));
}; };
const filterStatsByDate = (stats: { [key: string]: Stat[] }) => { const filterStatsByDate = (stats: {[key: string]: Stat[]}) => {
if (filter && filter !== "assignments") { if (filter && filter !== "assignments") {
const filterDate = moment() const filterDate = moment()
.subtract({ [filter as string]: 1 }) .subtract({[filter as string]: 1})
.format("x"); .format("x");
const filteredStats: { [key: string]: Stat[] } = {}; const filteredStats: {[key: string]: Stat[]} = {};
Object.keys(stats).forEach((timestamp) => { Object.keys(stats).forEach((timestamp) => {
if (timestamp >= filterDate) filteredStats[timestamp] = stats[timestamp]; if (timestamp >= filterDate) filteredStats[timestamp] = stats[timestamp];
@@ -118,7 +122,7 @@ export default function History({ user }: { user: User }) {
} }
if (filter && filter === "assignments") { if (filter && filter === "assignments") {
const filteredStats: { [key: string]: Stat[] } = {}; const filteredStats: {[key: string]: Stat[]} = {};
Object.keys(stats).forEach((timestamp) => { Object.keys(stats).forEach((timestamp) => {
if (stats[timestamp].map((s) => s.assignment === undefined).includes(false)) if (stats[timestamp].map((s) => s.assignment === undefined).includes(false))
@@ -137,13 +141,13 @@ export default function History({ user }: { user: User }) {
useEffect(() => { useEffect(() => {
const handleRouteChange = (url: string) => { const handleRouteChange = (url: string) => {
setTraining(false) setTraining(false);
} };
router.events.on('routeChangeStart', handleRouteChange) router.events.on("routeChangeStart", handleRouteChange);
return () => { return () => {
router.events.off('routeChangeStart', handleRouteChange) router.events.off("routeChangeStart", handleRouteChange);
} };
}, [router.events, setTraining]) }, [router.events, setTraining]);
const handleTrainingContentSubmission = () => { const handleTrainingContentSubmission = () => {
if (groupedStats) { if (groupedStats) {
@@ -156,11 +160,10 @@ export default function History({ user }: { user: User }) {
} }
return accumulator; return accumulator;
}, {}); }, {});
setTrainingStats(Object.values(selectedStats).flat()) setTrainingStats(Object.values(selectedStats).flat());
router.push("/training"); router.push("/training");
} }
} };
const customContent = (timestamp: string) => { const customContent = (timestamp: string) => {
if (!groupedStats) return <></>; if (!groupedStats) return <></>;
@@ -240,13 +243,13 @@ export default function History({ user }: { user: User }) {
const selectedUser = getSelectedUser(); const selectedUser = getSelectedUser();
const selectedUserSelectValue = selectedUser const selectedUserSelectValue = selectedUser
? { ? {
value: selectedUser.id, value: selectedUser.id,
label: `${selectedUser.name} - ${selectedUser.email}`, label: `${selectedUser.name} - ${selectedUser.email}`,
} }
: { : {
value: "", value: "",
label: "", label: "",
}; };
return ( return (
<> <>
<Head> <Head>
@@ -272,7 +275,7 @@ export default function History({ user }: { user: User }) {
value={selectableCorporates.find((x) => x.value === selectedCorporate)} value={selectableCorporates.find((x) => x.value === selectedCorporate)}
onChange={(value) => setSelectedCorporate(value?.value || "")} onChange={(value) => setSelectedCorporate(value?.value || "")}
styles={{ styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
@@ -289,7 +292,7 @@ export default function History({ user }: { user: User }) {
value={selectedUserSelectValue} value={selectedUserSelectValue}
onChange={(value) => setStatsUserId(value?.value!)} onChange={(value) => setStatsUserId(value?.value!)}
styles={{ styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
@@ -313,7 +316,7 @@ export default function History({ user }: { user: User }) {
value={selectedUserSelectValue} value={selectedUserSelectValue}
onChange={(value) => setStatsUserId(value?.value!)} onChange={(value) => setStatsUserId(value?.value!)}
styles={{ styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
@@ -323,10 +326,12 @@ export default function History({ user }: { user: User }) {
/> />
</> </>
)} )}
{(training && ( {training && (
<div className="flex flex-row"> <div className="flex flex-row">
<div className="font-semibold text-2xl mr-4">Select up to 10 exercises <div className="font-semibold text-2xl mr-4">
{`(${selectedTrainingExams.length}/${MAX_TRAINING_EXAMS})`}</div> Select up to 10 exercises
{`(${selectedTrainingExams.length}/${MAX_TRAINING_EXAMS})`}
</div>
<button <button
className={clsx( className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4 disabled:cursor-not-allowed", "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4 disabled:cursor-not-allowed",
@@ -337,7 +342,7 @@ export default function History({ user }: { user: User }) {
Submit Submit
</button> </button>
</div> </div>
))} )}
</div> </div>
<div className="flex gap-4 w-full justify-center xl:justify-end"> <div className="flex gap-4 w-full justify-center xl:justify-end">
<button <button

View File

@@ -28,7 +28,7 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
}; };
} }
if (shouldRedirectHome(user) || !["developer", "admin", "corporate", "agent", "mastercorporate"].includes(user.type)) { if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"])) {
return { return {
redirect: { redirect: {
destination: "/", destination: "/",

View File

@@ -71,7 +71,7 @@ export default function Stats() {
const {user} = useUser({redirectTo: "/login"}); const {user} = useUser({redirectTo: "/login"});
const {users} = useUsers(); const {users} = useUsers();
const {groups} = useGroups(user?.id); const {groups} = useGroups({admin: user?.id});
const {stats} = useStats(statsUserId, !statsUserId); const {stats} = useStats(statsUserId, !statsUserId);
useEffect(() => { useEffect(() => {
@@ -202,7 +202,7 @@ export default function Stats() {
}} }}
/> />
)} )}
{(["corporate", "teacher", "mastercorporate"].includes(user.type) ) && groups.length > 0 && ( {["corporate", "teacher", "mastercorporate"].includes(user.type) && groups.length > 0 && (
<Select <Select
className="w-full" className="w-full"
options={users options={users

View File

@@ -1,14 +1,14 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Head from "next/head"; import Head from "next/head";
import { withIronSessionSsr } from "iron-session/next"; import {withIronSessionSsr} from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import { Stat, User } from "@/interfaces/user"; import {Stat, User} from "@/interfaces/user";
import { ToastContainer } from "react-toastify"; import {ToastContainer} from "react-toastify";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import {shouldRedirectHome} from "@/utils/navigation.disabled";
import { use, useEffect, useState } from "react"; import {use, useEffect, useState} from "react";
import clsx from "clsx"; import clsx from "clsx";
import { FaPlus } from "react-icons/fa"; import {FaPlus} from "react-icons/fa";
import useRecordStore from "@/stores/recordStore"; import useRecordStore from "@/stores/recordStore";
import router from "next/router"; import router from "next/router";
import useTrainingContentStore from "@/stores/trainingContentStore"; import useTrainingContentStore from "@/stores/trainingContentStore";
@@ -16,390 +16,388 @@ import axios from "axios";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import { ITrainingContent } from "@/training/TrainingInterfaces"; import {ITrainingContent} from "@/training/TrainingInterfaces";
import moment from "moment"; import moment from "moment";
import { uuidv4 } from "@firebase/util"; import {uuidv4} from "@firebase/util";
import TrainingScore from "@/training/TrainingScore"; import TrainingScore from "@/training/TrainingScore";
import ModuleBadge from "@/components/ModuleBadge"; import ModuleBadge from "@/components/ModuleBadge";
export const getServerSideProps = withIronSessionSsr(({ req, res }) => { export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user; const user = req.session.user;
if (!user || !user.isVerified) { if (!user || !user.isVerified) {
return { return {
redirect: { redirect: {
destination: "/login", destination: "/login",
permanent: false, permanent: false,
}, },
}; };
} }
if (shouldRedirectHome(user)) { if (shouldRedirectHome(user)) {
return { return {
redirect: { redirect: {
destination: "/", destination: "/",
permanent: false, permanent: false,
}, },
}; };
} }
return { return {
props: { user: req.session.user }, props: {user: req.session.user},
}; };
}, sessionOptions); }, sessionOptions);
const defaultSelectableCorporate = { const defaultSelectableCorporate = {
value: "", value: "",
label: "All", label: "All",
}; };
const Training: React.FC<{ user: User }> = ({ user }) => { const Training: React.FC<{user: User}> = ({user}) => {
// Record stuff // Record stuff
const { users } = useUsers(); const {users} = useUsers();
const [selectedCorporate, setSelectedCorporate] = useState<string>(defaultSelectableCorporate.value); const [selectedCorporate, setSelectedCorporate] = useState<string>(defaultSelectableCorporate.value);
const [statsUserId, setStatsUserId, setRecordTraining] = useRecordStore((state) => [state.selectedUser, state.setSelectedUser, state.setTraining]); const [statsUserId, setStatsUserId, setRecordTraining] = useRecordStore((state) => [
const { groups: allGroups } = useGroups(); state.selectedUser,
const groups = allGroups.filter((x) => x.admin === user.id); state.setSelectedUser,
const [filter, setFilter] = useState<"months" | "weeks" | "days">(); state.setTraining,
]);
const {groups: allGroups} = useGroups({});
const groups = allGroups.filter((x) => x.admin === user.id);
const [filter, setFilter] = useState<"months" | "weeks" | "days">();
const toggleFilter = (value: "months" | "weeks" | "days") => { const toggleFilter = (value: "months" | "weeks" | "days") => {
setFilter((prev) => (prev === value ? undefined : value)); setFilter((prev) => (prev === value ? undefined : value));
}; };
const [stats, setTrainingStats] = useTrainingContentStore((state) => [state.stats, state.setStats]); const [stats, setTrainingStats] = useTrainingContentStore((state) => [state.stats, state.setStats]);
const [trainingContent, setTrainingContent] = useState<ITrainingContent[]>([]); const [trainingContent, setTrainingContent] = useState<ITrainingContent[]>([]);
const [isNewContentLoading, setIsNewContentLoading] = useState(stats.length != 0); const [isNewContentLoading, setIsNewContentLoading] = useState(stats.length != 0);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [groupedByTrainingContent, setGroupedByTrainingContent] = useState<{ [key: string]: ITrainingContent }>(); const [groupedByTrainingContent, setGroupedByTrainingContent] = useState<{[key: string]: ITrainingContent}>();
useEffect(() => { useEffect(() => {
const handleRouteChange = (url: string) => { const handleRouteChange = (url: string) => {
setTrainingStats([]) setTrainingStats([]);
} };
router.events.on('routeChangeStart', handleRouteChange) router.events.on("routeChangeStart", handleRouteChange);
return () => { return () => {
router.events.off('routeChangeStart', handleRouteChange) router.events.off("routeChangeStart", handleRouteChange);
} };
}, [router.events, setTrainingStats]) }, [router.events, setTrainingStats]);
useEffect(() => { useEffect(() => {
const postStats = async () => { const postStats = async () => {
try { try {
const response = await axios.post<{ id: string }>(`/api/training`, stats); const response = await axios.post<{id: string}>(`/api/training`, stats);
return response.data.id; return response.data.id;
} catch (error) { } catch (error) {
setIsNewContentLoading(false); setIsNewContentLoading(false);
} }
}; };
if (isNewContentLoading) { if (isNewContentLoading) {
postStats().then(id => { postStats().then((id) => {
setTrainingStats([]); setTrainingStats([]);
if (id) { if (id) {
router.push(`/training/${id}`) router.push(`/training/${id}`);
} }
}); });
} }
}, [isNewContentLoading]) }, [isNewContentLoading]);
useEffect(() => { useEffect(() => {
const loadTrainingContent = async () => { const loadTrainingContent = async () => {
try { try {
const response = await axios.get<ITrainingContent[]>('/api/training'); const response = await axios.get<ITrainingContent[]>("/api/training");
setTrainingContent(response.data); setTrainingContent(response.data);
setIsLoading(false); setIsLoading(false);
} catch (error) { } catch (error) {
setTrainingContent([]); setTrainingContent([]);
setIsLoading(false); setIsLoading(false);
} }
}; };
loadTrainingContent(); loadTrainingContent();
}, []); }, []);
const handleNewTrainingContent = () => { const handleNewTrainingContent = () => {
setRecordTraining(true); setRecordTraining(true);
router.push('/record') router.push("/record");
} };
const filterTrainingContentByDate = (trainingContent: {[key: string]: ITrainingContent}) => {
if (filter) {
const filterDate = moment()
.subtract({[filter as string]: 1})
.format("x");
const filteredTrainingContent: {[key: string]: ITrainingContent} = {};
const filterTrainingContentByDate = (trainingContent: { [key: string]: ITrainingContent }) => { Object.keys(trainingContent).forEach((timestamp) => {
if (filter) { if (timestamp >= filterDate) filteredTrainingContent[timestamp] = trainingContent[timestamp];
const filterDate = moment() });
.subtract({ [filter as string]: 1 }) return filteredTrainingContent;
.format("x"); }
const filteredTrainingContent: { [key: string]: ITrainingContent } = {}; return trainingContent;
};
Object.keys(trainingContent).forEach((timestamp) => { useEffect(() => {
if (timestamp >= filterDate) filteredTrainingContent[timestamp] = trainingContent[timestamp]; if (trainingContent.length > 0) {
}); const grouped = trainingContent.reduce((acc, content) => {
return filteredTrainingContent; acc[content.created_at] = content;
} return acc;
return trainingContent; }, {} as {[key: number]: ITrainingContent});
};
useEffect(() => { setGroupedByTrainingContent(grouped);
if (trainingContent.length > 0) { }
const grouped = trainingContent.reduce((acc, content) => { }, [trainingContent]);
acc[content.created_at] = content;
return acc;
}, {} as { [key: number]: ITrainingContent });
setGroupedByTrainingContent(grouped); // Record Stuff
} const selectableCorporates = [
}, [trainingContent]) defaultSelectableCorporate,
...users
.filter((x) => x.type === "corporate")
.map((x) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
})),
];
const getUsersList = (): User[] => {
if (selectedCorporate) {
// get groups for that corporate
const selectedCorporateGroups = allGroups.filter((x) => x.admin === selectedCorporate);
// Record Stuff // get the teacher ids for that group
const selectableCorporates = [ const selectedCorporateGroupsParticipants = selectedCorporateGroups.flatMap((x) => x.participants);
defaultSelectableCorporate,
...users
.filter((x) => x.type === "corporate")
.map((x) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
})),
];
const getUsersList = (): User[] => { // // search for groups for these teachers
if (selectedCorporate) { // const teacherGroups = allGroups.filter((x) => {
// get groups for that corporate // return selectedCorporateGroupsParticipants.includes(x.admin);
const selectedCorporateGroups = allGroups.filter((x) => x.admin === selectedCorporate); // });
// get the teacher ids for that group // const usersList = [
const selectedCorporateGroupsParticipants = selectedCorporateGroups.flatMap((x) => x.participants); // ...selectedCorporateGroupsParticipants,
// ...teacherGroups.flatMap((x) => x.participants),
// ];
const userListWithUsers = selectedCorporateGroupsParticipants.map((x) => users.find((y) => y.id === x)) as User[];
return userListWithUsers.filter((x) => x);
}
// // search for groups for these teachers return users || [];
// const teacherGroups = allGroups.filter((x) => { };
// return selectedCorporateGroupsParticipants.includes(x.admin);
// });
// const usersList = [ const corporateFilteredUserList = getUsersList();
// ...selectedCorporateGroupsParticipants, const getSelectedUser = () => {
// ...teacherGroups.flatMap((x) => x.participants), if (selectedCorporate) {
// ]; const userInCorporate = corporateFilteredUserList.find((x) => x.id === statsUserId);
const userListWithUsers = selectedCorporateGroupsParticipants.map((x) => users.find((y) => y.id === x)) as User[]; return userInCorporate || corporateFilteredUserList[0];
return userListWithUsers.filter((x) => x); }
}
return users || []; return users.find((x) => x.id === statsUserId) || user;
}; };
const corporateFilteredUserList = getUsersList(); const selectedUser = getSelectedUser();
const getSelectedUser = () => { const selectedUserSelectValue = selectedUser
if (selectedCorporate) { ? {
const userInCorporate = corporateFilteredUserList.find((x) => x.id === statsUserId); value: selectedUser.id,
return userInCorporate || corporateFilteredUserList[0]; label: `${selectedUser.name} - ${selectedUser.email}`,
} }
: {
value: "",
label: "",
};
return users.find((x) => x.id === statsUserId) || user; const formatTimestamp = (timestamp: string) => {
}; const date = moment(parseInt(timestamp));
const formatter = "YYYY/MM/DD - HH:mm";
const selectedUser = getSelectedUser(); return date.format(formatter);
const selectedUserSelectValue = selectedUser };
? {
value: selectedUser.id,
label: `${selectedUser.name} - ${selectedUser.email}`,
}
: {
value: "",
label: "",
};
const formatTimestamp = (timestamp: string) => { const selectTrainingContent = (trainingContent: ITrainingContent) => {
const date = moment(parseInt(timestamp)); router.push(`/training/${trainingContent.id}`);
const formatter = "YYYY/MM/DD - HH:mm"; };
return date.format(formatter); const trainingContentContainer = (timestamp: string) => {
}; if (!groupedByTrainingContent) return <></>;
const trainingContent: ITrainingContent = groupedByTrainingContent[timestamp];
const uniqueModules = [...new Set(trainingContent.exams.map((exam) => exam.module))];
const selectTrainingContent = (trainingContent: ITrainingContent) => { return (
router.push(`/training/${trainingContent.id}`) <>
}; <div
key={uuidv4()}
className={clsx(
"flex flex-col justify-between gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden",
)}
onClick={() => selectTrainingContent(trainingContent)}
role="button">
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
<div className="flex flex-col md:gap-1 -md:gap-2 2xl:gap-2">
<span className="font-medium">{formatTimestamp(timestamp)}</span>
</div>
<div className="flex flex-col gap-2">
<div className="w-full flex flex-row gap-1">
{uniqueModules.map((module) => (
<ModuleBadge key={module} module={module} />
))}
</div>
</div>
</div>
<TrainingScore trainingContent={trainingContent} gridView={true} />
</div>
</>
);
};
return (
<>
<Head>
<title>Training | 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 />
const trainingContentContainer = (timestamp: string) => { <Layout user={user}>
if (!groupedByTrainingContent) return <></>; {isNewContentLoading || isLoading ? (
const trainingContent: ITrainingContent = groupedByTrainingContent[timestamp]; <div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
const uniqueModules = [...new Set(trainingContent.exams.map(exam => exam.module))]; <span className="loading loading-infinity w-32 bg-mti-green-light" />
{isNewContentLoading && (
<span className="text-center text-2xl font-bold text-mti-green-light">Assessing your exams, please be patient...</span>
)}
</div>
) : (
<>
<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") && (
<>
<label className="font-normal text-base text-mti-gray-dim">Corporate</label>
return ( <Select
<> options={selectableCorporates}
<div value={selectableCorporates.find((x) => x.value === selectedCorporate)}
key={uuidv4()} onChange={(value) => setSelectedCorporate(value?.value || "")}
className={clsx( styles={{
"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" menuPortal: (base) => ({...base, zIndex: 9999}),
)} option: (styles, state) => ({
onClick={() => selectTrainingContent(trainingContent)} ...styles,
role="button"> backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
<div className="w-full flex justify-between -md:items-center 2xl:items-center"> color: state.isFocused ? "black" : styles.color,
<div className="flex flex-col md:gap-1 -md:gap-2 2xl:gap-2"> }),
<span className="font-medium">{formatTimestamp(timestamp)}</span> }}></Select>
</div> <label className="font-normal text-base text-mti-gray-dim">User</label>
<div className="flex flex-col gap-2">
<div className="w-full flex flex-row gap-1">
{uniqueModules.map((module) => (
<ModuleBadge key={module} module={module} />
))}
</div>
</div>
</div>
<TrainingScore
trainingContent={trainingContent}
gridView={true}
/>
</div>
</>
);
};
return ( <Select
<> options={corporateFilteredUserList.map((x) => ({
<Head> value: x.id,
<title>Training | EnCoach</title> label: `${x.name} - ${x.email}`,
<meta }))}
name="description" value={selectedUserSelectValue}
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." onChange={(value) => setStatsUserId(value?.value!)}
/> styles={{
<meta name="viewport" content="width=device-width, initial-scale=1" /> menuPortal: (base) => ({...base, zIndex: 9999}),
<link rel="icon" href="/favicon.ico" /> option: (styles, state) => ({
</Head> ...styles,
<ToastContainer /> backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</>
)}
{(user.type === "corporate" || user.type === "teacher") && groups.length > 0 && (
<>
<label className="font-normal text-base text-mti-gray-dim">User</label>
<Layout user={user}> <Select
{(isNewContentLoading || isLoading ? ( options={users
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12"> .filter((x) => groups.flatMap((y) => y.participants).includes(x.id))
<span className="loading loading-infinity w-32 bg-mti-green-light" /> .map((x) => ({
{isNewContentLoading && (<span className="text-center text-2xl font-bold text-mti-green-light"> value: x.id,
Assessing your exams, please be patient... label: `${x.name} - ${x.email}`,
</span>)} }))}
</div> value={selectedUserSelectValue}
) : ( onChange={(value) => setStatsUserId(value?.value!)}
<> styles={{
<div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center"> menuPortal: (base) => ({...base, zIndex: 9999}),
<div className="xl:w-3/4"> option: (styles, state) => ({
{(user.type === "developer" || user.type === "admin") && ( ...styles,
<> backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
<label className="font-normal text-base text-mti-gray-dim">Corporate</label> color: state.isFocused ? "black" : styles.color,
}),
<Select }}
options={selectableCorporates} />
value={selectableCorporates.find((x) => x.value === selectedCorporate)} </>
onChange={(value) => setSelectedCorporate(value?.value || "")} )}
styles={{ {user.type === "student" && (
menuPortal: (base) => ({ ...base, zIndex: 9999 }), <>
option: (styles, state) => ({ <div className="flex items-center">
...styles, <div className="font-semibold text-2xl">Generate New Training Material</div>
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", <button
color: state.isFocused ? "black" : styles.color, className={clsx(
}), "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4",
}}></Select> "transition duration-300 ease-in-out",
<label className="font-normal text-base text-mti-gray-dim">User</label> )}
onClick={handleNewTrainingContent}>
<Select <FaPlus />
options={corporateFilteredUserList.map((x) => ({ </button>
value: x.id, </div>
label: `${x.name} - ${x.email}`, </>
}))} )}
value={selectedUserSelectValue} </div>
onChange={(value) => setStatsUserId(value?.value!)} <div className="flex gap-4 w-full justify-center xl:justify-end">
styles={{ <button
menuPortal: (base) => ({ ...base, zIndex: 9999 }), className={clsx(
option: (styles, state) => ({ "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
...styles, "transition duration-300 ease-in-out",
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", filter === "months" && "!bg-mti-purple-light !text-white",
color: state.isFocused ? "black" : styles.color, )}
}), onClick={() => toggleFilter("months")}>
}} Last month
/> </button>
</> <button
)} className={clsx(
{(user.type === "corporate" || user.type === "teacher") && groups.length > 0 && ( "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
<> "transition duration-300 ease-in-out",
<label className="font-normal text-base text-mti-gray-dim">User</label> filter === "weeks" && "!bg-mti-purple-light !text-white",
)}
<Select onClick={() => toggleFilter("weeks")}>
options={users Last week
.filter((x) => groups.flatMap((y) => y.participants).includes(x.id)) </button>
.map((x) => ({ <button
value: x.id, className={clsx(
label: `${x.name} - ${x.email}`, "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
}))} "transition duration-300 ease-in-out",
value={selectedUserSelectValue} filter === "days" && "!bg-mti-purple-light !text-white",
onChange={(value) => setStatsUserId(value?.value!)} )}
styles={{ onClick={() => toggleFilter("days")}>
menuPortal: (base) => ({ ...base, zIndex: 9999 }), Last day
option: (styles, state) => ({ </button>
...styles, </div>
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", </div>
color: state.isFocused ? "black" : styles.color, {trainingContent.length == 0 && (
}), <div className="flex flex-grow justify-center items-center">
}} <span className="font-semibold ml-1">No training content to display...</span>
/> </div>
</> )}
)} {groupedByTrainingContent && Object.keys(groupedByTrainingContent).length > 0 && (
{(user.type === "student" && ( <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-3 w-full gap-4 xl:gap-6">
<> {Object.keys(filterTrainingContentByDate(groupedByTrainingContent))
<div className="flex items-center"> .sort((a, b) => parseInt(b) - parseInt(a))
<div className="font-semibold text-2xl">Generate New Training Material</div> .map(trainingContentContainer)}
<button </div>
className={clsx( )}
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4", </>
"transition duration-300 ease-in-out", )}
)} </Layout>
onClick={handleNewTrainingContent}> </>
<FaPlus /> );
</button> };
</div>
</>
))}
</div>
<div className="flex gap-4 w-full justify-center xl:justify-end">
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
filter === "months" && "!bg-mti-purple-light !text-white",
)}
onClick={() => toggleFilter("months")}>
Last month
</button>
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
filter === "weeks" && "!bg-mti-purple-light !text-white",
)}
onClick={() => toggleFilter("weeks")}>
Last week
</button>
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
filter === "days" && "!bg-mti-purple-light !text-white",
)}
onClick={() => toggleFilter("days")}>
Last day
</button>
</div>
</div>
{trainingContent.length == 0 && (
<div className="flex flex-grow justify-center items-center">
<span className="font-semibold ml-1">No training content to display...</span>
</div>
)}
{groupedByTrainingContent && Object.keys(groupedByTrainingContent).length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-3 w-full gap-4 xl:gap-6">
{Object.keys(filterTrainingContentByDate(groupedByTrainingContent))
.sort((a, b) => parseInt(b) - parseInt(a))
.map(trainingContentContainer)}
</div>
)}
</>
))}
</Layout>
</>
);
}
export default Training; export default Training;

View File

@@ -5,17 +5,17 @@ export const USER_TYPE_LABELS: {[key in Type]: string} = {
teacher: "Teacher", teacher: "Teacher",
corporate: "Corporate", corporate: "Corporate",
agent: "Country Manager", agent: "Country Manager",
admin: "Admin", admin: "Super Admin",
developer: "Developer", developer: "Developer",
mastercorporate: "Master Corporate" mastercorporate: "Master Corporate",
}; };
export function isCorporateUser(user: User): user is CorporateUser { export function isCorporateUser(user: User): user is CorporateUser {
return (user as CorporateUser).corporateInformation !== undefined; return (user as CorporateUser)?.corporateInformation !== undefined;
} }
export function isAgentUser(user: User): user is AgentUser { export function isAgentUser(user: User): user is AgentUser {
return (user as AgentUser).agentInformation !== undefined; return (user as AgentUser)?.agentInformation !== undefined;
} }
export function getUserCompanyName(user: User, users: User[], groups: Group[]) { export function getUserCompanyName(user: User, users: User[], groups: Group[]) {
@@ -30,3 +30,15 @@ export function getUserCompanyName(user: User, users: User[], groups: Group[]) {
const admin = belongingGroupsAdmins[0] as CorporateUser; const admin = belongingGroupsAdmins[0] as CorporateUser;
return admin.corporateInformation?.companyInformation.name || admin.name; return admin.corporateInformation?.companyInformation.name || admin.name;
} }
export function getCorporateUser(user: User, users: User[], groups: Group[]) {
if (isCorporateUser(user)) return user;
const belongingGroups = groups.filter((x) => x.participants.includes(user.id));
const belongingGroupsAdmins = belongingGroups.map((x) => users.find((u) => u.id === x.admin)).filter((x) => !!x && isCorporateUser(x));
if (belongingGroupsAdmins.length === 0) return undefined;
const admin = belongingGroupsAdmins[0] as CorporateUser;
return admin;
}

View File

@@ -1,5 +1,5 @@
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import {Exam, UserSolution} from "@/interfaces/exam"; import {Exam, ShuffleMap, UserSolution} from "@/interfaces/exam";
import {Assignment} from "@/interfaces/results"; import {Assignment} from "@/interfaces/results";
import {create} from "zustand"; import {create} from "zustand";
@@ -18,6 +18,7 @@ export interface ExamState {
exerciseIndex: number; exerciseIndex: number;
questionIndex: number; questionIndex: number;
inactivity: number; inactivity: number;
shuffleMaps: ShuffleMap[];
} }
export interface ExamFunctions { export interface ExamFunctions {
@@ -35,6 +36,7 @@ export interface ExamFunctions {
setExerciseIndex: (exerciseIndex: number) => void; setExerciseIndex: (exerciseIndex: number) => void;
setQuestionIndex: (questionIndex: number) => void; setQuestionIndex: (questionIndex: number) => void;
setInactivity: (inactivity: number) => void; setInactivity: (inactivity: number) => void;
setShuffleMaps: (shuffleMaps: ShuffleMap[]) => void;
reset: () => void; reset: () => void;
} }
@@ -53,6 +55,7 @@ export const initialState: ExamState = {
exerciseIndex: -1, exerciseIndex: -1,
questionIndex: 0, questionIndex: 0,
inactivity: 0, inactivity: 0,
shuffleMaps: []
}; };
const useExamStore = create<ExamState & ExamFunctions>((set) => ({ const useExamStore = create<ExamState & ExamFunctions>((set) => ({
@@ -72,6 +75,7 @@ const useExamStore = create<ExamState & ExamFunctions>((set) => ({
setExerciseIndex: (exerciseIndex: number) => set(() => ({exerciseIndex})), setExerciseIndex: (exerciseIndex: number) => set(() => ({exerciseIndex})),
setQuestionIndex: (questionIndex: number) => set(() => ({questionIndex})), setQuestionIndex: (questionIndex: number) => set(() => ({questionIndex})),
setInactivity: (inactivity: number) => set(() => ({inactivity})), setInactivity: (inactivity: number) => set(() => ({inactivity})),
setShuffleMaps: (shuffleMaps) => set(() => ({shuffleMaps})),
reset: () => set(() => initialState), reset: () => set(() => initialState),
})); }));

View File

@@ -1,90 +1,4 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer utilities {
.scrollbar-hide {
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
/* Chrome, Safari and Opera */
}
}
.training-scrollbar::-webkit-scrollbar {
@apply w-1.5;
}
.training-scrollbar::-webkit-scrollbar-track {
@apply bg-transparent;
}
.training-scrollbar::-webkit-scrollbar-thumb {
@apply bg-gray-400 hover:bg-gray-500 rounded-full transition-colors opacity-50 hover:opacity-75;
}
.training-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
}
:root {
--max-width: 1100px;
--border-radius: 12px;
--font-mono: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro",
"Fira Mono", "Droid Sans Mono", "Courier New", monospace;
--foreground-rgb: 53, 51, 56;
--background-start-rgb: 245, 245, 245;
--background-end-rgb: 245, 245, 245;
--primary-glow: conic-gradient(from 180deg at 50% 50%, #16abff33 0deg, #0885ff33 55deg, #54d6ff33 120deg, #0071ff33 160deg, transparent 360deg);
--secondary-glow: radial-gradient(rgba(255, 255, 255, 1), rgba(255, 255, 255, 0));
--tile-start-rgb: 239, 245, 249;
--tile-end-rgb: 228, 232, 233;
--tile-border: conic-gradient(#00000080, #00000040, #00000030, #00000020, #00000010, #00000010, #00000080);
--callout-rgb: 238, 240, 241;
--callout-border-rgb: 172, 175, 176;
--card-rgb: 180, 185, 188;
--card-border-rgb: 131, 134, 135;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
html {
min-height: 100vh !important;
height: 100%;
max-width: 100vw;
overflow-x: hidden;
overflow-y: auto;
font-family: "Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif;
}
body {
min-height: 100vh !important;
height: 100%;
max-width: 100vw;
overflow-x: hidden;
font-family: "Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif;
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(to bottom, transparent, rgb(var(--background-end-rgb))) rgb(var(--background-start-rgb));
}
a {
color: inherit;
text-decoration: none;
}

View File

@@ -1,5 +1,6 @@
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import {LevelScore} from "@/constants/ielts"; import {LevelScore} from "@/constants/ielts";
import {Stat, User} from "@/interfaces/user";
type Type = "academic" | "general"; type Type = "academic" | "general";
@@ -178,3 +179,28 @@ export const getLevelLabel = (level: number) => {
return ["Proficiency", "C2"]; return ["Proficiency", "C2"];
}; };
export const averageLevelCalculator = (users: User[], 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);
};

View File

@@ -137,5 +137,6 @@ export const convertToUserSolutions = (stats: Stat[]): UserSolution[] => {
solutions: stat.solutions, solutions: stat.solutions,
type: stat.type, type: stat.type,
module: stat.module, module: stat.module,
shuffleMaps: stat.shuffleMaps
})); }));
}; };

View File

@@ -3,13 +3,37 @@ module.exports = {
content: ["./src/**/*.{html,tsx,ts,js,jsx}"], content: ["./src/**/*.{html,tsx,ts,js,jsx}"],
safelist: [ safelist: [
{ {
pattern: /bg-ai-detection-result-(ai|mixed|human)/, pattern: /bg-ai-detection-result-(ai|mixed|human)/,
} },
], ],
theme: { theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: { extend: {
keyframes: {
"accordion-down": {
from: {height: "0"},
to: {height: "var(--radix-accordion-content-height)"},
},
"accordion-up": {
from: {height: "var(--radix-accordion-content-height)"},
to: {height: "0"},
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
scale: {
102: "1.02",
},
boxShadow: { boxShadow: {
'training-inset': 'inset 0px 2px 18px 0px #00000029', "training-inset": "inset 0px 2px 18px 0px #00000029",
}, },
colors: { colors: {
mti: { mti: {
@@ -42,18 +66,18 @@ module.exports = {
"ai-detection": { "ai-detection": {
result: { result: {
ai: {DEFAULT: "#f4bf4f", text: "#f0bc4f", bg: "#fff8e8"}, ai: {DEFAULT: "#f4bf4f", text: "#f0bc4f", bg: "#fff8e8"},
mixed: {DEFAULT:"#93aafb", bg: "rgba(147, 170, 251, 0.3)"}, mixed: {DEFAULT: "#93aafb", bg: "rgba(147, 170, 251, 0.3)"},
human: {DEFAULT:"#50c08a", bg: "#e9f9ed"} human: {DEFAULT: "#50c08a", bg: "#e9f9ed"},
}, },
confidence: { confidence: {
high: {DEFAULT: "#84d1ac", transparent: "#daf0e3"}, high: {DEFAULT: "#84d1ac", transparent: "#daf0e3"},
medium: {DEFAULT: "#f7ec88", transparent: "#fcf8d8"}, medium: {DEFAULT: "#f7ec88", transparent: "#fcf8d8"},
low: {DEFAULT: "#ffc1c1", transparent: "#ffebe9"}, low: {DEFAULT: "#ffc1c1", transparent: "#ffebe9"},
border: "#888888" border: "#888888",
}, },
highlight: "#ffefb7", highlight: "#ffefb7",
text: "#8992B1" text: "#8992B1",
} },
}, },
screens: { screens: {
"-sm": {max: "639px"}, "-sm": {max: "639px"},
@@ -65,5 +89,5 @@ module.exports = {
}, },
}, },
}, },
plugins: [require("daisyui"), require("tailwind-scrollbar-hide"),], plugins: [require("daisyui"), require("tailwind-scrollbar-hide"), require("tailwindcss-animate")],
}; };

594
yarn.lock
View File

@@ -149,6 +149,14 @@
"@dnd-kit/utilities" "^3.2.2" "@dnd-kit/utilities" "^3.2.2"
tslib "^2.0.0" tslib "^2.0.0"
"@dnd-kit/sortable@^8.0.0":
version "8.0.0"
resolved "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz"
integrity sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==
dependencies:
"@dnd-kit/utilities" "^3.2.2"
tslib "^2.0.0"
"@dnd-kit/utilities@^3.2.2": "@dnd-kit/utilities@^3.2.2":
version "3.2.2" version "3.2.2"
resolved "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz" resolved "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz"
@@ -740,6 +748,14 @@
dependencies: dependencies:
"@floating-ui/utils" "^0.2.5" "@floating-ui/utils" "^0.2.5"
"@floating-ui/dom@^1.0.0":
version "1.6.10"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.10.tgz#b74c32f34a50336c86dcf1f1c845cf3a39e26d6f"
integrity sha512-fskgCFv8J8OamCmyun8MfjB1Olfn+uZKjOKZ0vhYF3gRmEUXcGOjxWL8bBr7i4kIuPZ2KD2S3EUIOxnjC8kl2A==
dependencies:
"@floating-ui/core" "^1.6.0"
"@floating-ui/utils" "^0.2.7"
"@floating-ui/dom@^1.0.1", "@floating-ui/dom@^1.6.1": "@floating-ui/dom@^1.0.1", "@floating-ui/dom@^1.6.1":
version "1.6.8" version "1.6.8"
resolved "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.8.tgz" resolved "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.8.tgz"
@@ -748,11 +764,32 @@
"@floating-ui/core" "^1.6.0" "@floating-ui/core" "^1.6.0"
"@floating-ui/utils" "^0.2.5" "@floating-ui/utils" "^0.2.5"
"@floating-ui/react-dom@^2.0.0", "@floating-ui/react-dom@^2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.1.tgz#cca58b6b04fc92b4c39288252e285e0422291fb0"
integrity sha512-4h84MJt3CHrtG18mGsXuLCHMrug49d7DFkU0RMIyshRveBeyV2hmV/pDaF2Uxtu8kgq5r46llp5E5FQiR0K2Yg==
dependencies:
"@floating-ui/dom" "^1.0.0"
"@floating-ui/react@^0.26.16":
version "0.26.22"
resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.26.22.tgz#b46f645f9cd19a591da706aed24608c23cdb89a2"
integrity sha512-LNv4azPt8SpT4WW7Kku5JNVjLk2GcS0bGGjFTAgqOONRFo9r/aaGHHPpdiIuQbB1t8shmWyWqTTUDmZ9fcNshg==
dependencies:
"@floating-ui/react-dom" "^2.1.1"
"@floating-ui/utils" "^0.2.7"
tabbable "^6.0.0"
"@floating-ui/utils@^0.2.5": "@floating-ui/utils@^0.2.5":
version "0.2.5" version "0.2.5"
resolved "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.5.tgz" resolved "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.5.tgz"
integrity sha512-sTcG+QZ6fdEUObICavU+aB3Mp8HY4n14wYHdxK4fXjPmv3PXZZeY5RaguJmGyeH/CJQhX3fqKUtS4qc1LoHwhQ== integrity sha512-sTcG+QZ6fdEUObICavU+aB3Mp8HY4n14wYHdxK4fXjPmv3PXZZeY5RaguJmGyeH/CJQhX3fqKUtS4qc1LoHwhQ==
"@floating-ui/utils@^0.2.7":
version "0.2.7"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.7.tgz#d0ece53ce99ab5a8e37ebdfe5e32452a2bfc073e"
integrity sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==
"@google-cloud/firestore@^6.8.0": "@google-cloud/firestore@^6.8.0":
version "6.8.0" version "6.8.0"
resolved "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-6.8.0.tgz" resolved "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-6.8.0.tgz"
@@ -843,12 +880,15 @@
protobufjs "^7.0.0" protobufjs "^7.0.0"
yargs "^16.2.0" yargs "^16.2.0"
"@headlessui/react@^1.7.13": "@headlessui/react@^2.1.2":
version "1.7.14" version "2.1.2"
resolved "https://registry.npmjs.org/@headlessui/react/-/react-1.7.14.tgz" resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-2.1.2.tgz#3ca9378d7d0db6aefdb135f957815790786214ef"
integrity sha512-znzdq9PG8rkwcu9oQ2FwIy0ZFtP9Z7ycS+BAqJ3R5EIqC/0bJGvhT7193rFf+45i9nnPsYvCQVW4V/bB9Xc+gA== integrity sha512-Kb3hgk9gRNRcTZktBrKdHhF3xFhYkca1Rk6e1/im2ENf83dgN54orMW0uSKTXFnUpZOUFZ+wcY05LlipwgZIFQ==
dependencies: dependencies:
client-only "^0.0.1" "@floating-ui/react" "^0.26.16"
"@react-aria/focus" "^3.17.1"
"@react-aria/interactions" "^3.21.3"
"@tanstack/react-virtual" "^3.8.1"
"@humanwhocodes/config-array@^0.11.8": "@humanwhocodes/config-array@^0.11.8":
version "0.11.8" version "0.11.8"
@@ -952,10 +992,10 @@
dependencies: dependencies:
prop-types "^15.7.2" prop-types "^15.7.2"
"@next/env@13.1.6": "@next/env@14.2.5":
version "13.1.6" version "14.2.5"
resolved "https://registry.npmjs.org/@next/env/-/env-13.1.6.tgz" resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.5.tgz#1d9328ab828711d3517d0a1d505acb55e5ef7ad0"
integrity sha512-s+W9Fdqh5MFk6ECrbnVmmAOwxKQuhGMT7xXHrkYIBMBcTiOqNWhv5KbJIboKR5STXxNXl32hllnvKaffzFaWQg== integrity sha512-/zZGkrTOsraVfYjGP8uM0p6r0BDT6xWpkjdVbcz66PJVSpwXX3yNiRycxAuDfBKGWBrZBXRuK/YVlkNgxHGwmA==
"@next/eslint-plugin-next@13.1.6": "@next/eslint-plugin-next@13.1.6":
version "13.1.6" version "13.1.6"
@@ -964,75 +1004,50 @@
dependencies: dependencies:
glob "7.1.7" glob "7.1.7"
"@next/font@13.1.6": "@next/swc-darwin-arm64@14.2.5":
version "13.1.6" version "14.2.5"
resolved "https://registry.npmjs.org/@next/font/-/font-13.1.6.tgz" resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.5.tgz#d0a160cf78c18731c51cc0bff131c706b3e9bb05"
integrity sha512-AITjmeb1RgX1HKMCiA39ztx2mxeAyxl4ljv2UoSBUGAbFFMg8MO7YAvjHCgFhD39hL7YTbFjol04e/BPBH5RzQ== integrity sha512-/9zVxJ+K9lrzSGli1///ujyRfon/ZneeZ+v4ptpiPoOU+GKZnm8Wj8ELWU1Pm7GHltYRBklmXMTUqM/DqQ99FQ==
"@next/swc-android-arm-eabi@13.1.6": "@next/swc-darwin-x64@14.2.5":
version "13.1.6" version "14.2.5"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-13.1.6.tgz#d766dfc10e27814d947b20f052067c239913dbcc" resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.5.tgz#eb832a992407f6e6352eed05a073379f1ce0589c"
integrity sha512-F3/6Z8LH/pGlPzR1AcjPFxx35mPqjE5xZcf+IL+KgbW9tMkp7CYi1y7qKrEWU7W4AumxX/8OINnDQWLiwLasLQ== integrity sha512-vXHOPCwfDe9qLDuq7U1OYM2wUY+KQ4Ex6ozwsKxp26BlJ6XXbHleOUldenM67JRyBfVjv371oneEvYd3H2gNSA==
"@next/swc-android-arm64@13.1.6": "@next/swc-linux-arm64-gnu@14.2.5":
version "13.1.6" version "14.2.5"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-13.1.6.tgz#f37a98d5f18927d8c9970d750d516ac779465176" resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.5.tgz#098fdab57a4664969bc905f5801ef5a89582c689"
integrity sha512-cMwQjnB8vrYkWyK/H0Rf2c2pKIH4RGjpKUDvbjVAit6SbwPDpmaijLio0LWFV3/tOnY6kvzbL62lndVA0mkYpw== integrity sha512-vlhB8wI+lj8q1ExFW8lbWutA4M2ZazQNvMWuEDqZcuJJc78iUnLdPPunBPX8rC4IgT6lIx/adB+Cwrl99MzNaA==
"@next/swc-darwin-arm64@13.1.6": "@next/swc-linux-arm64-musl@14.2.5":
version "13.1.6" version "14.2.5"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.1.6.tgz#ec1b90fd9bf809d8b81004c5182e254dced4ad96" resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.5.tgz#243a1cc1087fb75481726dd289c7b219fa01f2b5"
integrity sha512-KKRQH4DDE4kONXCvFMNBZGDb499Hs+xcFAwvj+rfSUssIDrZOlyfJNy55rH5t2Qxed1e4K80KEJgsxKQN1/fyw== integrity sha512-NpDB9NUR2t0hXzJJwQSGu1IAOYybsfeB+LxpGsXrRIb7QOrYmidJz3shzY8cM6+rO4Aojuef0N/PEaX18pi9OA==
"@next/swc-darwin-x64@13.1.6": "@next/swc-linux-x64-gnu@14.2.5":
version "13.1.6" version "14.2.5"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-13.1.6.tgz#e869ac75d16995eee733a7d1550322d9051c1eb4" resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.5.tgz#b8a2e436387ee4a52aa9719b718992e0330c4953"
integrity sha512-/uOky5PaZDoaU99ohjtNcDTJ6ks/gZ5ykTQDvNZDjIoCxFe3+t06bxsTPY6tAO6uEAw5f6vVFX5H5KLwhrkZCA== integrity sha512-8XFikMSxWleYNryWIjiCX+gU201YS+erTUidKdyOVYi5qUQo/gRxv/3N1oZFCgqpesN6FPeqGM72Zve+nReVXQ==
"@next/swc-freebsd-x64@13.1.6": "@next/swc-linux-x64-musl@14.2.5":
version "13.1.6" version "14.2.5"
resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-13.1.6.tgz#84a7b2e423a2904afc2edca21c2f1ba6b53fa4c1" resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.5.tgz#cb8a9adad5fb8df86112cfbd363aab5c6d32757b"
integrity sha512-qaEALZeV7to6weSXk3Br80wtFQ7cFTpos/q+m9XVRFggu+8Ib895XhMWdJBzew6aaOcMvYR6KQ6JmHA2/eMzWw== integrity sha512-6QLwi7RaYiQDcRDSU/os40r5o06b5ue7Jsk5JgdRBGGp8l37RZEh9JsLSM8QF0YDsgcosSeHjglgqi25+m04IQ==
"@next/swc-linux-arm-gnueabihf@13.1.6": "@next/swc-win32-arm64-msvc@14.2.5":
version "13.1.6" version "14.2.5"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-13.1.6.tgz#980eed1f655ff8a72187d8a6ef9e73ac39d20d23" resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.5.tgz#81f996c1c38ea0900d4e7719cc8814be8a835da0"
integrity sha512-OybkbC58A1wJ+JrJSOjGDvZzrVEQA4sprJejGqMwiZyLqhr9Eo8FXF0y6HL+m1CPCpPhXEHz/2xKoYsl16kNqw== integrity sha512-1GpG2VhbspO+aYoMOQPQiqc/tG3LzmsdBH0LhnDS3JrtDx2QmzXe0B6mSZZiN3Bq7IOMXxv1nlsjzoS1+9mzZw==
"@next/swc-linux-arm64-gnu@13.1.6": "@next/swc-win32-ia32-msvc@14.2.5":
version "13.1.6" version "14.2.5"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.1.6.tgz#87a71db21cded3f7c63d1d19079845c59813c53d" resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.5.tgz#f61c74ce823e10b2bc150e648fc192a7056422e0"
integrity sha512-yCH+yDr7/4FDuWv6+GiYrPI9kcTAO3y48UmaIbrKy8ZJpi7RehJe3vIBRUmLrLaNDH3rY1rwoHi471NvR5J5NQ== integrity sha512-Igh9ZlxwvCDsu6438FXlQTHlRno4gFpJzqPjSIBZooD22tKeI4fE/YMRoHVJHmrQ2P5YL1DoZ0qaOKkbeFWeMg==
"@next/swc-linux-arm64-musl@13.1.6": "@next/swc-win32-x64-msvc@14.2.5":
version "13.1.6" version "14.2.5"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.1.6.tgz#c5aac8619331b9fd030603bbe2b36052011e11de" resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.5.tgz#ed199a920efb510cfe941cd75ed38a7be21e756f"
integrity sha512-ECagB8LGX25P9Mrmlc7Q/TQBb9rGScxHbv/kLqqIWs2fIXy6Y/EiBBiM72NTwuXUFCNrWR4sjUPSooVBJJ3ESQ== integrity sha512-tEQ7oinq1/CjSG9uSTerca3v4AZ+dFa+4Yu6ihaG8Ud8ddqLQgFGcnwYls13H5X5CPDPZJdYxyeMui6muOLd4g==
"@next/swc-linux-x64-gnu@13.1.6":
version "13.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.1.6.tgz#9513d36d540bbfea575576746736054c31aacdea"
integrity sha512-GT5w2mruk90V/I5g6ScuueE7fqj/d8Bui2qxdw6lFxmuTgMeol5rnzAv4uAoVQgClOUO/MULilzlODg9Ib3Y4Q==
"@next/swc-linux-x64-musl@13.1.6":
version "13.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.1.6.tgz#d61fc6884899f5957251f4ce3f522e34a2c479b7"
integrity sha512-keFD6KvwOPzmat4TCnlnuxJCQepPN+8j3Nw876FtULxo8005Y9Ghcl7ACcR8GoiKoddAq8gxNBrpjoxjQRHeAQ==
"@next/swc-win32-arm64-msvc@13.1.6":
version "13.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.1.6.tgz#fac2077a8ae9768e31444c9ae90807e64117cda7"
integrity sha512-OwertslIiGQluFvHyRDzBCIB07qJjqabAmINlXUYt7/sY7Q7QPE8xVi5beBxX/rxTGPIbtyIe3faBE6Z2KywhQ==
"@next/swc-win32-ia32-msvc@13.1.6":
version "13.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.1.6.tgz#498bc11c91b4c482a625bf4b978f98ae91111e46"
integrity sha512-g8zowiuP8FxUR9zslPmlju7qYbs2XBtTLVSxVikPtUDQedhcls39uKYLvOOd1JZg0ehyhopobRoH1q+MHlIN/w==
"@next/swc-win32-x64-msvc@13.1.6":
version "13.1.6"
resolved "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.1.6.tgz"
integrity sha512-Ls2OL9hi3YlJKGNdKv8k3X/lLgc3VmLG3a/DeTkAd+lAituJp8ZHmRmm9f9SL84fT3CotlzcgbdaCDfFwFA6bA==
"@nodelib/fs.scandir@2.1.5": "@nodelib/fs.scandir@2.1.5":
version "2.1.5" version "2.1.5"
@@ -1186,6 +1201,214 @@
resolved "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz" resolved "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz"
integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==
"@radix-ui/primitive@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.0.tgz#42ef83b3b56dccad5d703ae8c42919a68798bbe2"
integrity sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==
"@radix-ui/react-arrow@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz#744f388182d360b86285217e43b6c63633f39e7a"
integrity sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==
dependencies:
"@radix-ui/react-primitive" "2.0.0"
"@radix-ui/react-compose-refs@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz#656432461fc8283d7b591dcf0d79152fae9ecc74"
integrity sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==
"@radix-ui/react-context@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.0.tgz#6df8d983546cfd1999c8512f3a8ad85a6e7fcee8"
integrity sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==
"@radix-ui/react-dismissable-layer@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.0.tgz#2cd0a49a732372513733754e6032d3fb7988834e"
integrity sha512-/UovfmmXGptwGcBQawLzvn2jOfM0t4z3/uKffoBlj724+n3FvBbZ7M0aaBOmkp6pqFYpO4yx8tSVJjx3Fl2jig==
dependencies:
"@radix-ui/primitive" "1.1.0"
"@radix-ui/react-compose-refs" "1.1.0"
"@radix-ui/react-primitive" "2.0.0"
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-use-escape-keydown" "1.1.0"
"@radix-ui/react-focus-guards@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.0.tgz#8e9abb472a9a394f59a1b45f3dd26cfe3fc6da13"
integrity sha512-w6XZNUPVv6xCpZUqb/yN9DL6auvpGX3C/ee6Hdi16v2UUy25HV2Q5bcflsiDyT/g5RwbPQ/GIT1vLkeRb+ITBw==
"@radix-ui/react-focus-scope@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz#ebe2891a298e0a33ad34daab2aad8dea31caf0b2"
integrity sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==
dependencies:
"@radix-ui/react-compose-refs" "1.1.0"
"@radix-ui/react-primitive" "2.0.0"
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-icons@^1.3.0":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-icons/-/react-icons-1.3.0.tgz#c61af8f323d87682c5ca76b856d60c2312dbcb69"
integrity sha512-jQxj/0LKgp+j9BiTXz3O3sgs26RNet2iLWmsPyRz2SIcR4q/4SbazXfnYwbAr+vLYKSfc7qxzyGQA1HLlYiuNw==
"@radix-ui/react-id@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.1.0.tgz#de47339656594ad722eb87f94a6b25f9cffae0ed"
integrity sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==
dependencies:
"@radix-ui/react-use-layout-effect" "1.1.0"
"@radix-ui/react-popover@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.1.1.tgz#604b783cdb3494ed4f16a58c17f0e81e61ab7775"
integrity sha512-3y1A3isulwnWhvTTwmIreiB8CF4L+qRjZnK1wYLO7pplddzXKby/GnZ2M7OZY3qgnl6p9AodUIHRYGXNah8Y7g==
dependencies:
"@radix-ui/primitive" "1.1.0"
"@radix-ui/react-compose-refs" "1.1.0"
"@radix-ui/react-context" "1.1.0"
"@radix-ui/react-dismissable-layer" "1.1.0"
"@radix-ui/react-focus-guards" "1.1.0"
"@radix-ui/react-focus-scope" "1.1.0"
"@radix-ui/react-id" "1.1.0"
"@radix-ui/react-popper" "1.2.0"
"@radix-ui/react-portal" "1.1.1"
"@radix-ui/react-presence" "1.1.0"
"@radix-ui/react-primitive" "2.0.0"
"@radix-ui/react-slot" "1.1.0"
"@radix-ui/react-use-controllable-state" "1.1.0"
aria-hidden "^1.1.1"
react-remove-scroll "2.5.7"
"@radix-ui/react-popper@1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.2.0.tgz#a3e500193d144fe2d8f5d5e60e393d64111f2a7a"
integrity sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==
dependencies:
"@floating-ui/react-dom" "^2.0.0"
"@radix-ui/react-arrow" "1.1.0"
"@radix-ui/react-compose-refs" "1.1.0"
"@radix-ui/react-context" "1.1.0"
"@radix-ui/react-primitive" "2.0.0"
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-use-layout-effect" "1.1.0"
"@radix-ui/react-use-rect" "1.1.0"
"@radix-ui/react-use-size" "1.1.0"
"@radix-ui/rect" "1.1.0"
"@radix-ui/react-portal@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.1.tgz#1957f1eb2e1aedfb4a5475bd6867d67b50b1d15f"
integrity sha512-A3UtLk85UtqhzFqtoC8Q0KvR2GbXF3mtPgACSazajqq6A41mEQgo53iPzY4i6BwDxlIFqWIhiQ2G729n+2aw/g==
dependencies:
"@radix-ui/react-primitive" "2.0.0"
"@radix-ui/react-use-layout-effect" "1.1.0"
"@radix-ui/react-presence@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.0.tgz#227d84d20ca6bfe7da97104b1a8b48a833bfb478"
integrity sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==
dependencies:
"@radix-ui/react-compose-refs" "1.1.0"
"@radix-ui/react-use-layout-effect" "1.1.0"
"@radix-ui/react-primitive@2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz#fe05715faa9203a223ccc0be15dc44b9f9822884"
integrity sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==
dependencies:
"@radix-ui/react-slot" "1.1.0"
"@radix-ui/react-slot@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.1.0.tgz#7c5e48c36ef5496d97b08f1357bb26ed7c714b84"
integrity sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==
dependencies:
"@radix-ui/react-compose-refs" "1.1.0"
"@radix-ui/react-use-callback-ref@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz#bce938ca413675bc937944b0d01ef6f4a6dc5bf1"
integrity sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==
"@radix-ui/react-use-controllable-state@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz#1321446857bb786917df54c0d4d084877aab04b0"
integrity sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==
dependencies:
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-use-escape-keydown@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz#31a5b87c3b726504b74e05dac1edce7437b98754"
integrity sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==
dependencies:
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-use-layout-effect@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz#3c2c8ce04827b26a39e442ff4888d9212268bd27"
integrity sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==
"@radix-ui/react-use-rect@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz#13b25b913bd3e3987cc9b073a1a164bb1cf47b88"
integrity sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==
dependencies:
"@radix-ui/rect" "1.1.0"
"@radix-ui/react-use-size@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz#b4dba7fbd3882ee09e8d2a44a3eed3a7e555246b"
integrity sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==
dependencies:
"@radix-ui/react-use-layout-effect" "1.1.0"
"@radix-ui/rect@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.0.tgz#f817d1d3265ac5415dadc67edab30ae196696438"
integrity sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==
"@react-aria/focus@^3.17.1":
version "3.18.1"
resolved "https://registry.yarnpkg.com/@react-aria/focus/-/focus-3.18.1.tgz#b54b88e78662549ddae917e3143723c8dd7a4e90"
integrity sha512-N0Cy61WCIv+57mbqC7hiZAsB+3rF5n4JKabxUmg/2RTJL6lq7hJ5N4gx75ymKxkN8GnVDwt4pKZah48Wopa5jw==
dependencies:
"@react-aria/interactions" "^3.22.1"
"@react-aria/utils" "^3.25.1"
"@react-types/shared" "^3.24.1"
"@swc/helpers" "^0.5.0"
clsx "^2.0.0"
"@react-aria/interactions@^3.21.3", "@react-aria/interactions@^3.22.1":
version "3.22.1"
resolved "https://registry.yarnpkg.com/@react-aria/interactions/-/interactions-3.22.1.tgz#f2219a100c886cee383da7be9ae05e9dd940d39a"
integrity sha512-5TLzQaDAQQ5C70yG8GInbO4wIylKY67RfTIIwQPGR/4n5OIjbUD8BOj3NuSsuZ/frUPaBXo1VEBBmSO23fxkjw==
dependencies:
"@react-aria/ssr" "^3.9.5"
"@react-aria/utils" "^3.25.1"
"@react-types/shared" "^3.24.1"
"@swc/helpers" "^0.5.0"
"@react-aria/ssr@^3.9.5":
version "3.9.5"
resolved "https://registry.yarnpkg.com/@react-aria/ssr/-/ssr-3.9.5.tgz#775d84f51f90934ff51ae74eeba3728daac1a381"
integrity sha512-xEwGKoysu+oXulibNUSkXf8itW0npHHTa6c4AyYeZIJyRoegeteYuFpZUBPtIDE8RfHdNsSmE1ssOkxRnwbkuQ==
dependencies:
"@swc/helpers" "^0.5.0"
"@react-aria/utils@^3.25.1":
version "3.25.1"
resolved "https://registry.yarnpkg.com/@react-aria/utils/-/utils-3.25.1.tgz#f6530ce47aa28617924cc6868b4cf1c113a909c5"
integrity sha512-5Uj864e7T5+yj78ZfLnfHqmypLiqW2mN+nsdslog2z5ssunTqjolVeM15ootXskjISlZ7MojLpq97kIC4nlnAw==
dependencies:
"@react-aria/ssr" "^3.9.5"
"@react-stately/utils" "^3.10.2"
"@react-types/shared" "^3.24.1"
"@swc/helpers" "^0.5.0"
clsx "^2.0.0"
"@react-pdf/fns@2.2.1": "@react-pdf/fns@2.2.1":
version "2.2.1" version "2.2.1"
resolved "https://registry.npmjs.org/@react-pdf/fns/-/fns-2.2.1.tgz" resolved "https://registry.npmjs.org/@react-pdf/fns/-/fns-2.2.1.tgz"
@@ -1365,18 +1588,50 @@
"@react-spring/shared" "~9.7.4" "@react-spring/shared" "~9.7.4"
"@react-spring/types" "~9.7.4" "@react-spring/types" "~9.7.4"
"@react-stately/utils@^3.10.2":
version "3.10.2"
resolved "https://registry.yarnpkg.com/@react-stately/utils/-/utils-3.10.2.tgz#09377f771592ff537c901aa64178cb3a004a916f"
integrity sha512-fh6OTQtbeQC0ywp6LJuuKs6tKIgFvt/DlIZEcIpGho6/oZG229UnIk6TUekwxnDbumuYyan6D9EgUtEMmT8UIg==
dependencies:
"@swc/helpers" "^0.5.0"
"@react-types/shared@^3.24.1":
version "3.24.1"
resolved "https://registry.yarnpkg.com/@react-types/shared/-/shared-3.24.1.tgz#fa06cb681d144fce9c515d8bd296d81440a45d25"
integrity sha512-AUQeGYEm/zDTN6zLzdXolDxz3Jk5dDL7f506F07U8tBwxNNI3WRdhU84G0/AaFikOZzDXhOZDr3MhQMzyE7Ydw==
"@rushstack/eslint-patch@^1.1.3": "@rushstack/eslint-patch@^1.1.3":
version "1.2.0" version "1.2.0"
resolved "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz" resolved "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz"
integrity sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg== integrity sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==
"@swc/helpers@0.4.14", "@swc/helpers@^0.4.2": "@swc/counter@^0.1.3":
version "0.1.3"
resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9"
integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==
"@swc/helpers@0.5.5":
version "0.5.5"
resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.5.tgz#12689df71bfc9b21c4f4ca00ae55f2f16c8b77c0"
integrity sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==
dependencies:
"@swc/counter" "^0.1.3"
tslib "^2.4.0"
"@swc/helpers@^0.4.2":
version "0.4.14" version "0.4.14"
resolved "https://registry.npmjs.org/@swc/helpers/-/helpers-0.4.14.tgz" resolved "https://registry.npmjs.org/@swc/helpers/-/helpers-0.4.14.tgz"
integrity sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw== integrity sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw==
dependencies: dependencies:
tslib "^2.4.0" tslib "^2.4.0"
"@swc/helpers@^0.5.0":
version "0.5.12"
resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.12.tgz#37aaca95284019eb5d2207101249435659709f4b"
integrity sha512-KMZNXiGibsW9kvZAO1Pam2JPTDBm+KSHMMHWdsyI/1DbIZjT2A6Gy3hblVXUMEDvUAKq+e0vL0X0o54owWji7g==
dependencies:
tslib "^2.4.0"
"@tanstack/react-table@^8.10.1": "@tanstack/react-table@^8.10.1":
version "8.19.3" version "8.19.3"
resolved "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.19.3.tgz" resolved "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.19.3.tgz"
@@ -1384,11 +1639,23 @@
dependencies: dependencies:
"@tanstack/table-core" "8.19.3" "@tanstack/table-core" "8.19.3"
"@tanstack/react-virtual@^3.8.1":
version "3.9.0"
resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.9.0.tgz#728e3a1917cb98fb67a17f4190e75f531f1eb0de"
integrity sha512-5TeTSQBMV1PIFzBP9cduIX5klRaTvbOw+CxRx3LaUhwqiZLEZBZqz8anEIqG4eHNhDAe+BLarRDeNE9cNM1/EA==
dependencies:
"@tanstack/virtual-core" "3.9.0"
"@tanstack/table-core@8.19.3": "@tanstack/table-core@8.19.3":
version "8.19.3" version "8.19.3"
resolved "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.19.3.tgz" resolved "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.19.3.tgz"
integrity sha512-IqREj9ADoml9zCAouIG/5kCGoyIxPFdqdyoxis9FisXFi5vT+iYfEfLosq4xkU/iDbMcEuAj+X8dWRLvKYDNoQ== integrity sha512-IqREj9ADoml9zCAouIG/5kCGoyIxPFdqdyoxis9FisXFi5vT+iYfEfLosq4xkU/iDbMcEuAj+X8dWRLvKYDNoQ==
"@tanstack/virtual-core@3.9.0":
version "3.9.0"
resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.9.0.tgz#60db41fe8a19bb1a21873d86d8731416391e838a"
integrity sha512-Saga7/QRGej/IDCVP5BgJ1oDqlDT2d9rQyoflS3fgMS8ntJ8JGw/LBqK2GorHa06+VrNFc0tGz65XQHJQJetFQ==
"@tootallnate/once@2": "@tootallnate/once@2":
version "2.0.0" version "2.0.0"
resolved "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz" resolved "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz"
@@ -1935,6 +2202,13 @@ argparse@^2.0.1:
resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz" resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
aria-hidden@^1.1.1:
version "1.2.4"
resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.4.tgz#b78e383fdbc04d05762c78b4a25a501e736c4522"
integrity sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==
dependencies:
tslib "^2.0.0"
aria-query@^5.1.3: aria-query@^5.1.3:
version "5.1.3" version "5.1.3"
resolved "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz" resolved "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz"
@@ -2251,10 +2525,19 @@ buffer@^6:
base64-js "^1.3.1" base64-js "^1.3.1"
ieee754 "^1.2.1" ieee754 "^1.2.1"
<<<<<<< HEAD
buffers@~0.1.1: buffers@~0.1.1:
version "0.1.1" version "0.1.1"
resolved "https://registry.yarnpkg.com/buffers/-/buffers-0.1.1.tgz#b24579c3bed4d6d396aeee6d9a8ae7f5482ab7bb" resolved "https://registry.yarnpkg.com/buffers/-/buffers-0.1.1.tgz#b24579c3bed4d6d396aeee6d9a8ae7f5482ab7bb"
integrity sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ== integrity sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==
=======
busboy@1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893"
integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==
dependencies:
streamsearch "^1.1.0"
>>>>>>> develop
call-bind@^1.0.2, call-bind@^1.0.7: call-bind@^1.0.2, call-bind@^1.0.7:
version "1.0.7" version "1.0.7"
@@ -2282,11 +2565,16 @@ camelcase@^5.0.0:
resolved "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz" resolved "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz"
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
caniuse-lite@^1.0.30001406, caniuse-lite@^1.0.30001449, caniuse-lite@^1.0.30001464: caniuse-lite@^1.0.30001449, caniuse-lite@^1.0.30001464:
version "1.0.30001480" version "1.0.30001480"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001480.tgz" resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001480.tgz"
integrity sha512-q7cpoPPvZYgtyC4VaBSN0Bt+PJ4c4EYRf0DrduInOz2SkFpHD5p3LnvEpqBp7UnJn+8x1Ogl1s38saUxe+ihQQ== integrity sha512-q7cpoPPvZYgtyC4VaBSN0Bt+PJ4c4EYRf0DrduInOz2SkFpHD5p3LnvEpqBp7UnJn+8x1Ogl1s38saUxe+ihQQ==
caniuse-lite@^1.0.30001579:
version "1.0.30001651"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz#52de59529e8b02b1aedcaaf5c05d9e23c0c28138"
integrity sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==
catharsis@^0.9.0: catharsis@^0.9.0:
version "0.9.0" version "0.9.0"
resolved "https://registry.npmjs.org/catharsis/-/catharsis-0.9.0.tgz" resolved "https://registry.npmjs.org/catharsis/-/catharsis-0.9.0.tgz"
@@ -2345,12 +2633,19 @@ chownr@^2.0.0:
resolved "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz" resolved "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz"
integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==
class-variance-authority@^0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/class-variance-authority/-/class-variance-authority-0.7.0.tgz#1c3134d634d80271b1837452b06d821915954522"
integrity sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==
dependencies:
clsx "2.0.0"
classnames@^2.2.6, classnames@^2.3.0, classnames@^2.5.1: classnames@^2.2.6, classnames@^2.3.0, classnames@^2.5.1:
version "2.5.1" version "2.5.1"
resolved "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz" resolved "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz"
integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow== integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==
client-only@0.0.1, client-only@^0.0.1: client-only@0.0.1:
version "0.0.1" version "0.0.1"
resolved "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz" resolved "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz"
integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==
@@ -2378,11 +2673,21 @@ clone@^2.1.2:
resolved "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz" resolved "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz"
integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w== integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==
clsx@^1.1.1, clsx@^1.2.1: clsx@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.0.0.tgz#12658f3fd98fafe62075595a5c30e43d18f3d00b"
integrity sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==
clsx@^1.1.1:
version "1.2.1" version "1.2.1"
resolved "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz" resolved "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz"
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
clsx@^2.0.0, clsx@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
color-convert@^1.9.0: color-convert@^1.9.0:
version "1.9.3" version "1.9.3"
resolved "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz" resolved "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz"
@@ -2716,6 +3021,11 @@ detect-libc@^2.0.0:
resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz" resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz"
integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw== integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==
detect-node-es@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493"
integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==
dezalgo@^1.0.4: dezalgo@^1.0.4:
version "1.0.4" version "1.0.4"
resolved "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz" resolved "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz"
@@ -3640,6 +3950,11 @@ get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.4:
has-symbols "^1.0.3" has-symbols "^1.0.3"
hasown "^2.0.0" hasown "^2.0.0"
get-nonce@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3"
integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==
get-symbol-description@^1.0.0: get-symbol-description@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz" resolved "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz"
@@ -4045,6 +4360,13 @@ internal-slot@^1.0.3, internal-slot@^1.0.4:
has "^1.0.3" has "^1.0.3"
side-channel "^1.0.4" side-channel "^1.0.4"
invariant@^2.2.4:
version "2.2.4"
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
dependencies:
loose-envify "^1.0.0"
iron-session@^6.3.1: iron-session@^6.3.1:
version "6.3.1" version "6.3.1"
resolved "https://registry.npmjs.org/iron-session/-/iron-session-6.3.1.tgz" resolved "https://registry.npmjs.org/iron-session/-/iron-session-6.3.1.tgz"
@@ -4893,7 +5215,7 @@ mz@^2.7.0:
object-assign "^4.0.1" object-assign "^4.0.1"
thenify-all "^1.0.0" thenify-all "^1.0.0"
nanoid@^3.3.4, nanoid@^3.3.6: nanoid@^3.3.6:
version "3.3.6" version "3.3.6"
resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz" resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz"
integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==
@@ -4908,30 +5230,28 @@ neo-async@^2.6.2:
resolved "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz" resolved "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz"
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
next@13.1.6: next@^14.2.5:
version "13.1.6" version "14.2.5"
resolved "https://registry.npmjs.org/next/-/next-13.1.6.tgz" resolved "https://registry.yarnpkg.com/next/-/next-14.2.5.tgz#afe4022bb0b752962e2205836587a289270efbea"
integrity sha512-hHlbhKPj9pW+Cymvfzc15lvhaOZ54l+8sXDXJWm3OBNBzgrVj6hwGPmqqsXg40xO1Leq+kXpllzRPuncpC0Phw== integrity sha512-0f8aRfBVL+mpzfBjYfQuLWh2WyAwtJXCRfkPF4UJ5qd2YwrHczsrSzXU4tRMV0OAxR8ZJZWPFn6uhSC56UTsLA==
dependencies: dependencies:
"@next/env" "13.1.6" "@next/env" "14.2.5"
"@swc/helpers" "0.4.14" "@swc/helpers" "0.5.5"
caniuse-lite "^1.0.30001406" busboy "1.6.0"
postcss "8.4.14" caniuse-lite "^1.0.30001579"
graceful-fs "^4.2.11"
postcss "8.4.31"
styled-jsx "5.1.1" styled-jsx "5.1.1"
optionalDependencies: optionalDependencies:
"@next/swc-android-arm-eabi" "13.1.6" "@next/swc-darwin-arm64" "14.2.5"
"@next/swc-android-arm64" "13.1.6" "@next/swc-darwin-x64" "14.2.5"
"@next/swc-darwin-arm64" "13.1.6" "@next/swc-linux-arm64-gnu" "14.2.5"
"@next/swc-darwin-x64" "13.1.6" "@next/swc-linux-arm64-musl" "14.2.5"
"@next/swc-freebsd-x64" "13.1.6" "@next/swc-linux-x64-gnu" "14.2.5"
"@next/swc-linux-arm-gnueabihf" "13.1.6" "@next/swc-linux-x64-musl" "14.2.5"
"@next/swc-linux-arm64-gnu" "13.1.6" "@next/swc-win32-arm64-msvc" "14.2.5"
"@next/swc-linux-arm64-musl" "13.1.6" "@next/swc-win32-ia32-msvc" "14.2.5"
"@next/swc-linux-x64-gnu" "13.1.6" "@next/swc-win32-x64-msvc" "14.2.5"
"@next/swc-linux-x64-musl" "13.1.6"
"@next/swc-win32-arm64-msvc" "13.1.6"
"@next/swc-win32-ia32-msvc" "13.1.6"
"@next/swc-win32-x64-msvc" "13.1.6"
nice-try@^1.0.4: nice-try@^1.0.4:
version "1.0.5" version "1.0.5"
@@ -5306,12 +5626,12 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.1.0, postcss-value-parser@^
resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz" resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@8.4.14: postcss@8.4.31:
version "8.4.14" version "8.4.31"
resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d"
integrity sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig== integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==
dependencies: dependencies:
nanoid "^3.3.4" nanoid "^3.3.6"
picocolors "^1.0.0" picocolors "^1.0.0"
source-map-js "^1.0.2" source-map-js "^1.0.2"
@@ -5659,6 +5979,25 @@ react-popper@^2.2.5, react-popper@^2.3.0:
react-fast-compare "^3.0.1" react-fast-compare "^3.0.1"
warning "^4.0.2" warning "^4.0.2"
react-remove-scroll-bar@^2.3.4:
version "2.3.6"
resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz#3e585e9d163be84a010180b18721e851ac81a29c"
integrity sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==
dependencies:
react-style-singleton "^2.2.1"
tslib "^2.0.0"
react-remove-scroll@2.5.7:
version "2.5.7"
resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz#15a1fd038e8497f65a695bf26a4a57970cac1ccb"
integrity sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==
dependencies:
react-remove-scroll-bar "^2.3.4"
react-style-singleton "^2.2.1"
tslib "^2.1.0"
use-callback-ref "^1.3.0"
use-sidecar "^1.1.2"
react-select@^5.7.5: react-select@^5.7.5:
version "5.8.0" version "5.8.0"
resolved "https://registry.npmjs.org/react-select/-/react-select-5.8.0.tgz" resolved "https://registry.npmjs.org/react-select/-/react-select-5.8.0.tgz"
@@ -5679,6 +6018,15 @@ react-string-replace@^1.1.0:
resolved "https://registry.npmjs.org/react-string-replace/-/react-string-replace-1.1.0.tgz" resolved "https://registry.npmjs.org/react-string-replace/-/react-string-replace-1.1.0.tgz"
integrity sha512-N6RalSDFGbOHs0IJi1H611WbZsvk3ZT47Jl2JEXFbiS3kTwsdCYij70Keo/tWtLy7sfhDsYm7CwNM/WmjXIaMw== integrity sha512-N6RalSDFGbOHs0IJi1H611WbZsvk3ZT47Jl2JEXFbiS3kTwsdCYij70Keo/tWtLy7sfhDsYm7CwNM/WmjXIaMw==
react-style-singleton@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4"
integrity sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==
dependencies:
get-nonce "^1.0.0"
invariant "^2.2.4"
tslib "^2.0.0"
react-toastify@^9.1.2: react-toastify@^9.1.2:
version "9.1.2" version "9.1.2"
resolved "https://registry.npmjs.org/react-toastify/-/react-toastify-9.1.2.tgz" resolved "https://registry.npmjs.org/react-toastify/-/react-toastify-9.1.2.tgz"
@@ -6083,6 +6431,7 @@ stream-shift@^1.0.2:
resolved "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz" resolved "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz"
integrity sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ== integrity sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==
<<<<<<< HEAD
"string-width-cjs@npm:string-width@^4.2.0": "string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3" version "4.2.3"
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
@@ -6093,6 +6442,14 @@ stream-shift@^1.0.2:
strip-ansi "^6.0.1" strip-ansi "^6.0.1"
"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
=======
streamsearch@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
>>>>>>> develop
version "4.2.3" version "4.2.3"
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -6269,11 +6626,26 @@ synckit@^0.8.4:
"@pkgr/utils" "^2.3.1" "@pkgr/utils" "^2.3.1"
tslib "^2.5.0" tslib "^2.5.0"
tabbable@^6.0.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97"
integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==
tailwind-merge@^2.5.2:
version "2.5.2"
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.5.2.tgz#000f05a703058f9f9f3829c644235f81d4c08a1f"
integrity sha512-kjEBm+pvD+6eAwzJL2Bi+02/9LFLal1Gs61+QB7HvTfQQ0aXwC5LGT8PEt1gS0CWKktKe6ysPTAy3cBC5MeiIg==
tailwind-scrollbar-hide@^1.1.7: tailwind-scrollbar-hide@^1.1.7:
version "1.1.7" version "1.1.7"
resolved "https://registry.npmjs.org/tailwind-scrollbar-hide/-/tailwind-scrollbar-hide-1.1.7.tgz" resolved "https://registry.npmjs.org/tailwind-scrollbar-hide/-/tailwind-scrollbar-hide-1.1.7.tgz"
integrity sha512-X324n9OtpTmOMqEgDUEA/RgLrNfBF/jwJdctaPZDzB3mppxJk7TLIDmOreEDm1Bq4R9LSPu4Epf8VSdovNU+iA== integrity sha512-X324n9OtpTmOMqEgDUEA/RgLrNfBF/jwJdctaPZDzB3mppxJk7TLIDmOreEDm1Bq4R9LSPu4Epf8VSdovNU+iA==
tailwindcss-animate@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz#318b692c4c42676cc9e67b19b78775742388bef4"
integrity sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==
tailwindcss@^3, tailwindcss@^3.2.4: tailwindcss@^3, tailwindcss@^3.2.4:
version "3.3.1" version "3.3.1"
resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.1.tgz" resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.1.tgz"
@@ -6560,6 +6932,13 @@ uri-js@^4.2.2:
dependencies: dependencies:
punycode "^2.1.0" punycode "^2.1.0"
use-callback-ref@^1.3.0:
version "1.3.2"
resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.2.tgz#6134c7f6ff76e2be0b56c809b17a650c942b1693"
integrity sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==
dependencies:
tslib "^2.0.0"
use-file-picker@^2.1.0: use-file-picker@^2.1.0:
version "2.1.2" version "2.1.2"
resolved "https://registry.npmjs.org/use-file-picker/-/use-file-picker-2.1.2.tgz" resolved "https://registry.npmjs.org/use-file-picker/-/use-file-picker-2.1.2.tgz"
@@ -6572,6 +6951,14 @@ use-isomorphic-layout-effect@^1.1.2:
resolved "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz" resolved "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz"
integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA== integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==
use-sidecar@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.2.tgz#2f43126ba2d7d7e117aa5855e5d8f0276dfe73c2"
integrity sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==
dependencies:
detect-node-es "^1.1.0"
tslib "^2.0.0"
use-sync-external-store@1.2.0, use-sync-external-store@^1.2.0: use-sync-external-store@1.2.0, use-sync-external-store@^1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz" resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz"
@@ -6720,7 +7107,12 @@ wordwrap@^1.0.0:
resolved "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz" resolved "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
<<<<<<< HEAD
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
=======
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
name wrap-ansi-cjs
>>>>>>> develop
version "7.0.0" version "7.0.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==