Compare commits
116 Commits
feature/up
...
ENCOA-83_M
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
720597e916 | ||
|
|
e74ded676e | ||
|
|
ee60eedd0d | ||
|
|
c37a1becbf | ||
|
|
b9cca483ec | ||
|
|
c758bdaf9e | ||
|
|
5ada588b16 | ||
|
|
eec1bb0c30 | ||
|
|
65f8368708 | ||
|
|
806e621c5b | ||
|
|
ce35b23714 | ||
|
|
2cd025b118 | ||
|
|
2e699d7e25 | ||
|
|
30da295c60 | ||
|
|
a82a399d52 | ||
|
|
505df31d6b | ||
|
|
a4d8ba72af | ||
|
|
2bfd0cb502 | ||
|
|
5ee071028c | ||
|
|
23b9452a3a | ||
|
|
0ce3a16d3a | ||
|
|
4315a7b17c | ||
|
|
247f192a0a | ||
|
|
9c944ae3d2 | ||
|
|
a390aa429d | ||
|
|
3367384791 | ||
|
|
158324a705 | ||
|
|
f9286d1793 | ||
|
|
2e376c37dd | ||
|
|
5bda9ed227 | ||
|
|
97b533bd3a | ||
|
|
75a45108a2 | ||
|
|
bfc0def20f | ||
|
|
9db33e6a51 | ||
|
|
ba5d926659 | ||
|
|
1cd4dfc397 | ||
|
|
bf5dd62b35 | ||
|
|
4e583d11b6 | ||
|
|
688505b4eb | ||
|
|
81b8ceb2b3 | ||
|
|
d93d36c392 | ||
|
|
3299acee36 | ||
|
|
abddead402 | ||
|
|
2d69fdac3c | ||
|
|
506ff2503e | ||
|
|
5d191730d2 | ||
|
|
346b131388 | ||
|
|
aba49e385f | ||
|
|
5789688eab | ||
|
|
f7da11bc69 | ||
|
|
10802f6bb5 | ||
|
|
37e356572b | ||
|
|
8669ef462d | ||
|
|
df1c0bad4d | ||
|
|
bcb1a0f914 | ||
|
|
bf1bdd935c | ||
|
|
edc9d4de2a | ||
|
|
229275aaee | ||
|
|
f0ff6ac691 | ||
|
|
878c7c2ef0 | ||
|
|
0a28c2bd41 | ||
|
|
38e48c90bb | ||
|
|
c6f35d7750 | ||
|
|
85f684dff5 | ||
|
|
d94a9bb88a | ||
|
|
1950d5f15d | ||
|
|
e84cc8ddd8 | ||
|
|
cf2fd06d39 | ||
|
|
b6015b6433 | ||
|
|
fea58a7b40 | ||
|
|
13284eab75 | ||
|
|
dd4e3a4694 | ||
|
|
eb55e65d91 | ||
|
|
cb75ba6056 | ||
|
|
859d9283a7 | ||
|
|
1a3437b333 | ||
|
|
bbbf17daa0 | ||
|
|
ae79aef132 | ||
|
|
c3e71b4389 | ||
|
|
2784117862 | ||
|
|
8162567e12 | ||
|
|
58300e32ff | ||
|
|
cb489bf0ca | ||
|
|
91bc91e725 | ||
|
|
ce086a8b22 | ||
|
|
6e71ee7cb0 | ||
|
|
21e58e3b9c | ||
|
|
b885dd46b5 | ||
|
|
0fc2df1070 | ||
|
|
cf91f1812d | ||
|
|
3289f27cd5 | ||
|
|
80939d16a5 | ||
|
|
11b5490af4 | ||
|
|
a31070d4a3 | ||
|
|
95c3f89911 | ||
|
|
2a58e0d33f | ||
|
|
afe59f5a3a | ||
|
|
7fd56357e0 | ||
|
|
a4a40b9145 | ||
|
|
48faee07f6 | ||
|
|
f0d7d7644b | ||
|
|
309dfba583 | ||
|
|
cf64a91651 | ||
|
|
0f47a8af70 | ||
|
|
d0310f7c2b | ||
|
|
f6a0a391b9 | ||
|
|
8dd4dad096 | ||
|
|
96baa2a6e0 | ||
|
|
8dd557a29b | ||
|
|
4e30eda06f | ||
|
|
12bb124d91 | ||
|
|
a71e6632d6 | ||
|
|
36f518afca | ||
|
|
a534126c61 | ||
|
|
752a46b247 | ||
|
|
663b1aae4f |
@@ -54,4 +54,4 @@ EXPOSE 3000
|
|||||||
ENV PORT 3000
|
ENV PORT 3000
|
||||||
ENV HOSTNAME localhost
|
ENV HOSTNAME localhost
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
CMD HOSTNAME="0.0.0.0" node server.js
|
||||||
17
components.json
Normal file
17
components.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "src/styles/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": false,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils"
|
||||||
|
}
|
||||||
|
}
|
||||||
3607
package-lock.json
generated
3607
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@@ -12,28 +12,34 @@
|
|||||||
"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",
|
||||||
"@tanstack/react-table": "^8.10.1",
|
"@tanstack/react-table": "^8.10.1",
|
||||||
"@types/node": "18.13.0",
|
"@types/node": "18.13.0",
|
||||||
"@types/react": "18.0.27",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "18.0.10",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@use-gesture/react": "^10.3.1",
|
||||||
"axios": "^1.3.5",
|
"axios": "^1.3.5",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"chart.js": "^4.2.1",
|
"chart.js": "^4.2.1",
|
||||||
"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",
|
||||||
"daisyui": "^3.1.5",
|
"daisyui": "^3.1.5",
|
||||||
"eslint": "8.33.0",
|
"eslint": "8.33.0",
|
||||||
"eslint-config-next": "13.1.6",
|
"eslint-config-next": "13.1.6",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"express-handlebars": "^7.1.2",
|
"express-handlebars": "^7.1.2",
|
||||||
"firebase": "9.19.1",
|
"firebase": "9.19.1",
|
||||||
"firebase-admin": "^11.10.1",
|
"firebase-admin": "^11.10.1",
|
||||||
@@ -45,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",
|
||||||
@@ -74,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",
|
||||||
@@ -99,4 +107,4 @@
|
|||||||
"postcss": "^8.4.21",
|
"postcss": "^8.4.21",
|
||||||
"tailwindcss": "^3.2.4"
|
"tailwindcss": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import SegmentedProgressBar from "./SegmentedProgressBar";
|
|||||||
// Colors and texts scrapped from gpt's zero react bundle
|
// Colors and texts scrapped from gpt's zero react bundle
|
||||||
const AIDetection: React.FC<AIDetectionAttributes> = ({ predicted_class, confidence_category, class_probabilities, sentences }) => {
|
const AIDetection: React.FC<AIDetectionAttributes> = ({ predicted_class, confidence_category, class_probabilities, sentences }) => {
|
||||||
const probabilityTooltipContent = `
|
const probabilityTooltipContent = `
|
||||||
GTP's Zero deep learning model predicts the <br/>
|
Encoach's deep learning model predicts the <br/>
|
||||||
probability this text has been entirely <br/>
|
probability this text has been entirely <br/>
|
||||||
generated by AI. For instance, a 40% AI <br/>
|
generated by AI. For instance, a 40% AI <br/>
|
||||||
probability does not indicate that the text<br/>
|
probability does not indicate that the text<br/>
|
||||||
@@ -19,7 +19,7 @@ const AIDetection: React.FC<AIDetectionAttributes> = ({ predicted_class, confide
|
|||||||
`;
|
`;
|
||||||
const confidenceTooltipContent = `
|
const confidenceTooltipContent = `
|
||||||
Confidence scores are a safeguard to better<br/>
|
Confidence scores are a safeguard to better<br/>
|
||||||
understand AI identification results. GTP Zero<br/>
|
understand AI identification results. Encoach<br/>
|
||||||
trained it's deep learning model on a diverse<br/>
|
trained it's deep learning model on a diverse<br/>
|
||||||
dataset of millions of human and AI-written<br/>
|
dataset of millions of human and AI-written<br/>
|
||||||
documents. Green scores indicate that you can scan<br/>
|
documents. Green scores indicate that you can scan<br/>
|
||||||
@@ -32,19 +32,19 @@ const AIDetection: React.FC<AIDetectionAttributes> = ({ predicted_class, confide
|
|||||||
const confidenceKeywords = ["moderately", "highly", "confident", "uncertain"];
|
const confidenceKeywords = ["moderately", "highly", "confident", "uncertain"];
|
||||||
var confidence = {
|
var confidence = {
|
||||||
low: {
|
low: {
|
||||||
ai: "GPT Zero is uncertain about this text. If GPT Zero had to classify it, it would be considered",
|
ai: "Encoach is uncertain about this text. If Encoach had to classify it, it would be considered",
|
||||||
human: "GPT Zero is uncertain about this text. If GPT Zero had to classify it, it would likely be considered",
|
human: "Encoach is uncertain about this text. If Encoach had to classify it, it would likely be considered",
|
||||||
mixed: "GPT Zero is uncertain about this text. If GPT Zero had to classify it, it would likely be a"
|
mixed: "Encoach is uncertain about this text. If Encoach had to classify it, it would likely be a"
|
||||||
},
|
},
|
||||||
medium: {
|
medium: {
|
||||||
ai: "GPT Zero is moderately confident this text was",
|
ai: "Encoach is moderately confident this text was",
|
||||||
human: "GPT Zero is moderately confident this text is entirely",
|
human: "Encoach is moderately confident this text is entirely",
|
||||||
mixed: "GPT Zero is moderately confident this text is a"
|
mixed: "Encoach is moderately confident this text is a"
|
||||||
},
|
},
|
||||||
high: {
|
high: {
|
||||||
ai: "GPT Zero is highly confident this text was",
|
ai: "Encoach is highly confident this text was",
|
||||||
human: "GPT Zero is highly confident this text is entirely",
|
human: "Encoach is highly confident this text is entirely",
|
||||||
mixed: "GPT Zero is highly confident this text is a"
|
mixed: "Encoach is highly confident this text is a"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var classPrediction = {
|
var classPrediction = {
|
||||||
@@ -107,7 +107,7 @@ const AIDetection: React.FC<AIDetectionAttributes> = ({ predicted_class, confide
|
|||||||
<Tooltip id="probability-tooltip" className="z-50 bg-white shadow-md rounded-sm" style={tooltipStyle} />
|
<Tooltip id="probability-tooltip" className="z-50 bg-white shadow-md rounded-sm" style={tooltipStyle} />
|
||||||
<Tooltip id="confidence-tooltip" className="z-50 bg-white shadow-md rounded-sm" style={tooltipStyle} />
|
<Tooltip id="confidence-tooltip" className="z-50 bg-white shadow-md rounded-sm" style={tooltipStyle} />
|
||||||
<div className="flex flex-col bg-white p-6 rounded-lg shadow-lg gap-16">
|
<div className="flex flex-col bg-white p-6 rounded-lg shadow-lg gap-16">
|
||||||
<h1 className="text-lg font-semibold">GPT Zero AI Detection Results</h1>
|
<h1 className="text-lg font-semibold">Encoach Detection Results</h1>
|
||||||
<div className="flex flex-row -md:flex-col -lg:gap-0 -xl:gap-10 gap-20 items-stretch -md:items-center">
|
<div className="flex flex-row -md:flex-col -lg:gap-0 -xl:gap-10 gap-20 items-stretch -md:items-center">
|
||||||
<div className="flex -md:w-5/6 w-1/2 justify-center">
|
<div className="flex -md:w-5/6 w-1/2 justify-center">
|
||||||
<div className="flex flex-col border rounded-xl">
|
<div className="flex flex-col border rounded-xl">
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import {Dialog, Transition} from "@headlessui/react";
|
|
||||||
import {Fragment} from "react";
|
|
||||||
import Button from "./Low/Button";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: (next?: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BlankQuestionsModal({isOpen, onClose}: Props) {
|
|
||||||
return (
|
|
||||||
<Transition show={isOpen} as={Fragment}>
|
|
||||||
<Dialog onClose={() => onClose(false)} className="relative z-50">
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0">
|
|
||||||
<div className="fixed inset-0 bg-black/30" />
|
|
||||||
</Transition.Child>
|
|
||||||
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95">
|
|
||||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
|
||||||
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
|
|
||||||
<Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title>
|
|
||||||
<span>
|
|
||||||
Please note that you are finishing the current module and once you proceed to the next module, you will no longer be
|
|
||||||
able to change the answers in the current one, including your unanswered questions. <br />
|
|
||||||
<br />
|
|
||||||
Are you sure you want to continue without completing those questions?
|
|
||||||
</span>
|
|
||||||
<div className="w-full flex justify-between mt-8">
|
|
||||||
<Button color="purple" onClick={() => onClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
|
|
||||||
Go Back
|
|
||||||
</Button>
|
|
||||||
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full">
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</div>
|
|
||||||
</Transition.Child>
|
|
||||||
</Dialog>
|
|
||||||
</Transition>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
84
src/components/Dropdown.tsx
Normal file
84
src/components/Dropdown.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import React, { useState, ReactNode, useRef, useEffect } from 'react';
|
||||||
|
import { animated, useSpring } from '@react-spring/web';
|
||||||
|
|
||||||
|
interface DropdownProps {
|
||||||
|
title: ReactNode;
|
||||||
|
open?: boolean;
|
||||||
|
className?: string;
|
||||||
|
contentWrapperClassName?: string;
|
||||||
|
bottomPadding?: number;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Dropdown: React.FC<DropdownProps> = ({
|
||||||
|
title,
|
||||||
|
open = false,
|
||||||
|
className = "w-full text-left font-semibold flex justify-between items-center p-4",
|
||||||
|
contentWrapperClassName = "px-6",
|
||||||
|
bottomPadding = 12,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState<boolean>(open);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [contentHeight, setContentHeight] = useState<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
if (contentRef.current) {
|
||||||
|
resizeObserver = new ResizeObserver(entries => {
|
||||||
|
for (let entry of entries) {
|
||||||
|
if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
|
||||||
|
const height = entry.borderBoxSize[0].blockSize;
|
||||||
|
setContentHeight(height + bottomPadding);
|
||||||
|
} else {
|
||||||
|
// Fallback for browsers that don't support borderBoxSize
|
||||||
|
const height = entry.contentRect.height;
|
||||||
|
setContentHeight(height + bottomPadding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(contentRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [bottomPadding]);
|
||||||
|
|
||||||
|
const springProps = useSpring({
|
||||||
|
height: isOpen ? contentHeight : 0,
|
||||||
|
opacity: isOpen ? 1 : 0,
|
||||||
|
config: { tension: 300, friction: 30 }
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 transform transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<animated.div style={springProps} className="overflow-hidden">
|
||||||
|
<div ref={contentRef} className={contentWrapperClassName} style={{paddingBottom: bottomPadding}}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</animated.div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dropdown;
|
||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
64
src/components/Exercises/FillBlanks/WordsDrawer.tsx
Normal file
64
src/components/Exercises/FillBlanks/WordsDrawer.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface WordsDrawerProps {
|
||||||
|
words: {word: string; isDisabled: boolean}[];
|
||||||
|
isOpen: boolean;
|
||||||
|
blankId?: string;
|
||||||
|
previouslySelectedWord?: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onAnswer: (answer: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const WordsDrawer: React.FC<WordsDrawerProps> = ({words, isOpen, blankId, previouslySelectedWord, onCancel, onAnswer}) => {
|
||||||
|
const [selectedWord, setSelectedWord] = useState<string | undefined>(previouslySelectedWord);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"w-full h-full absolute top-0 left-0 bg-gradient-to-t from-mti-black to-transparent z-10",
|
||||||
|
isOpen ? "visible opacity-10" : "invisible opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"absolute w-full bg-white px-7 py-8 bottom-0 left-0 shadow-2xl rounded-2xl z-20 flex flex-col gap-8 transition-opacity duration-300 ease-in-out",
|
||||||
|
isOpen ? "visible opacity-100" : "invisible opacity-0",
|
||||||
|
)}>
|
||||||
|
<div className="w-full flex gap-2">
|
||||||
|
<div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-purple-light">{blankId}</div>
|
||||||
|
<span> Choose the correct word:</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-6 gap-6" key="word-array">
|
||||||
|
{words.map(({word, isDisabled}) => (
|
||||||
|
<button
|
||||||
|
key={`${word}_${blankId}`}
|
||||||
|
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
|
||||||
|
className={clsx(
|
||||||
|
"rounded-full py-3 text-center transition duration-300 ease-in-out",
|
||||||
|
selectedWord === word ? "text-white bg-mti-purple-light" : "bg-mti-purple-ultralight",
|
||||||
|
!isDisabled && "hover:text-white hover:bg-mti-purple",
|
||||||
|
"disabled:cursor-not-allowed disabled:text-mti-gray-dim",
|
||||||
|
)}
|
||||||
|
disabled={isDisabled}>
|
||||||
|
{word}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between w-full">
|
||||||
|
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WordsDrawer;
|
||||||
243
src/components/Exercises/FillBlanks/index.tsx
Normal file
243
src/components/Exercises/FillBlanks/index.tsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
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, exam, partIndex, questionIndex, exerciseIndex } = 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]);
|
||||||
|
|
||||||
|
|
||||||
|
let correctWords: any;
|
||||||
|
if (exam && exam.module === "level" && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") {
|
||||||
|
correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateScore = () => {
|
||||||
|
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||||
|
const correct = answers!.filter((x) => {
|
||||||
|
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
|
||||||
|
if (!solution) return false;
|
||||||
|
const option = correctWords!.find((w: any) => {
|
||||||
|
if (typeof w === "string") {
|
||||||
|
return w.toLowerCase() === x.solution.toLowerCase();
|
||||||
|
} else if ('letter' in w) {
|
||||||
|
return w.word.toLowerCase() === x.solution.toLowerCase();
|
||||||
|
} else {
|
||||||
|
return w.id.toString() === x.id.toString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!option) return false;
|
||||||
|
|
||||||
|
if (typeof option === "string") {
|
||||||
|
return solution.toLowerCase() === option.toLowerCase();
|
||||||
|
} else if ('letter' in option) {
|
||||||
|
return solution.toLowerCase() === option.word.toLowerCase();
|
||||||
|
} else if ('options' in option) {
|
||||||
|
return option.options[solution as keyof typeof option.options] == x.solution;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}).length;
|
||||||
|
const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||||
|
return { total, correct, missing };
|
||||||
|
};
|
||||||
|
const 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" ? (
|
||||||
|
<>
|
||||||
|
{/*<span className="mr-2">{`(${id})`}</span>*/}
|
||||||
|
<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">
|
||||||
|
{false && <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 flex flex-col gap-4 px-16 py-8">
|
||||||
|
<span className="font-medium text-lg text-mti-purple-dark mb-4 px-2">{`${currentMCSelection.id} - Select the appropriate word.`}</span>
|
||||||
|
<div className="flex gap-4 flex-wrap justify-between">
|
||||||
|
{currentMCSelection.selection?.options && Object.entries(currentMCSelection.selection.options).sort((a, b) => a[0].localeCompare(b[0])).map(([key, value]) => {
|
||||||
|
return <div
|
||||||
|
key={v4()}
|
||||||
|
onClick={() => onSelection(currentMCSelection.id, value)}
|
||||||
|
className={clsx(
|
||||||
|
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base",
|
||||||
|
!!answers.find((x) => x.solution.toLocaleLowerCase() === value.toLocaleLowerCase() && x.id === currentMCSelection.id) &&
|
||||||
|
"border-mti-purple-light",
|
||||||
|
)}>
|
||||||
|
<span className="font-semibold">{key}.</span>
|
||||||
|
<span>{value}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
/*<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"
|
||||||
|
disabled={
|
||||||
|
exam && typeof partIndex !== "undefined" && exam.module === "level" &&
|
||||||
|
typeof exam.parts[0].intro === "string" && questionIndex === 0}
|
||||||
|
>
|
||||||
|
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;
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
/* 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";
|
||||||
|
import { v4 } from "uuid";
|
||||||
|
|
||||||
function Question({
|
function Question({
|
||||||
id,
|
id,
|
||||||
@@ -14,22 +15,27 @@ 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>.*?<\/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 (
|
||||||
<div className="flex flex-col gap-10">
|
<div className="flex flex-col gap-8">
|
||||||
{isNaN(Number(id)) ? (
|
{isNaN(Number(id)) ? (
|
||||||
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
<span className="text-lg">{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="">
|
<span className="text-lg">
|
||||||
<>
|
<>
|
||||||
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")}</span>
|
||||||
</>
|
</>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -37,10 +43,10 @@ function Question({
|
|||||||
{variant === "image" &&
|
{variant === "image" &&
|
||||||
options.map((option) => (
|
options.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={option.id.toString()}
|
key={v4()}
|
||||||
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
||||||
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 select-none",
|
||||||
userSolution === option.id.toString() && "border-mti-purple-light",
|
userSolution === option.id.toString() && "border-mti-purple-light",
|
||||||
)}>
|
)}>
|
||||||
<span className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>{option.id.toString()}</span>
|
<span className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>{option.id.toString()}</span>
|
||||||
@@ -50,10 +56,10 @@ function Question({
|
|||||||
{variant === "text" &&
|
{variant === "text" &&
|
||||||
options.map((option) => (
|
options.map((option) => (
|
||||||
<div
|
<div
|
||||||
key={option.id.toString()}
|
key={v4()}
|
||||||
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm",
|
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-base select-none",
|
||||||
userSolution === option.id.toString() && "border-mti-purple-light",
|
userSolution === option.id.toString() && "border-mti-purple-light",
|
||||||
)}>
|
)}>
|
||||||
<span className="font-semibold">{option.id.toString()}.</span>
|
<span className="font-semibold">{option.id.toString()}.</span>
|
||||||
@@ -65,53 +71,84 @@ 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 {
|
||||||
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
|
questionIndex,
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
exam,
|
||||||
|
shuffleMaps,
|
||||||
|
hasExamEnded,
|
||||||
|
userSolutions: storeUserSolutions,
|
||||||
|
setQuestionIndex,
|
||||||
|
setUserSolutions
|
||||||
|
} = useExamStore((state) => state);
|
||||||
|
|
||||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||||
|
|
||||||
useEffect(() => {
|
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);
|
||||||
}
|
}
|
||||||
@@ -122,7 +159,7 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-2 mt-4 h-fit w-full mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8">
|
<div className="flex flex-col gap-2 mt-4 h-fit w-full mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
<span className="text-xl font-semibold">{prompt}</span>
|
{/*<span className="text-xl font-semibold mb-2">{"Select the appropriate option."}</span>*/}
|
||||||
{questionIndex < questions.length && (
|
{questionIndex < questions.length && (
|
||||||
<Question
|
<Question
|
||||||
{...questions[questionIndex]}
|
{...questions[questionIndex]}
|
||||||
@@ -133,7 +170,10 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full">
|
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full"
|
||||||
|
disabled={
|
||||||
|
exam && exam.module === "level" && typeof exam.parts[0].intro === "string" && questionIndex === 0}
|
||||||
|
>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,34 @@
|
|||||||
import {SpeakingExercise} from "@/interfaces/exam";
|
import { SpeakingExercise } from "@/interfaces/exam";
|
||||||
import {CommonProps} from ".";
|
import { CommonProps } from ".";
|
||||||
import {Fragment, useEffect, useState} from "react";
|
import { Fragment, useEffect, useState } from "react";
|
||||||
import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs";
|
import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {downloadBlob} from "@/utils/evaluation";
|
import { downloadBlob } from "@/utils/evaluation";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import Modal from "../Modal";
|
import Modal from "../Modal";
|
||||||
|
|
||||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
|
||||||
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function Speaking({id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
export default function Speaking({ id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack }: SpeakingExercise & CommonProps) {
|
||||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||||
const [audioURL, setAudioURL] = useState<string>();
|
const [audioURL, setAudioURL] = useState<string>();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isPromptsModalOpen, setIsPromptsModalOpen] = useState(false);
|
const [isPromptsModalOpen, setIsPromptsModalOpen] = useState(false);
|
||||||
|
const [inputText, setInputText] = useState("");
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
const saveToStorage = async () => {
|
const saveToStorage = async () => {
|
||||||
if (mediaBlob && mediaBlob.startsWith("blob")) {
|
if (mediaBlob && mediaBlob.startsWith("blob")) {
|
||||||
const blobBuffer = await downloadBlob(mediaBlob);
|
const blobBuffer = await downloadBlob(mediaBlob);
|
||||||
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
|
const audioFile = new File([blobBuffer], "audio.wav", { type: "audio/wav" });
|
||||||
|
|
||||||
const seed = Math.random().toString().replace("0.", "");
|
const seed = Math.random().toString().replace("0.", "");
|
||||||
|
|
||||||
@@ -41,8 +42,8 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await axios.post<{path: string}>("/api/storage/insert", formData, config);
|
const response = await axios.post<{ path: string }>("/api/storage/insert", formData, config);
|
||||||
if (audioURL) await axios.post("/api/storage/delete", {path: audioURL});
|
if (audioURL) await axios.post("/api/storage/delete", { path: audioURL });
|
||||||
return response.data.path;
|
return response.data.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userSolutions.length > 0) {
|
if (userSolutions.length > 0) {
|
||||||
const {solution} = userSolutions[0] as {solution?: string};
|
const { solution } = userSolutions[0] as { solution?: string };
|
||||||
if (solution && !mediaBlob) setMediaBlob(solution);
|
if (solution && !mediaBlob) setMediaBlob(solution);
|
||||||
if (solution && !solution.startsWith("blob")) setAudioURL(solution);
|
if (solution && !solution.startsWith("blob")) setAudioURL(solution);
|
||||||
}
|
}
|
||||||
@@ -78,8 +79,8 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
|||||||
const next = async () => {
|
const next = async () => {
|
||||||
onNext({
|
onNext({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
|
||||||
score: {correct: 0, total: 100, missing: 0},
|
score: { correct: 0, total: 100, missing: 0 },
|
||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -87,12 +88,33 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
|||||||
const back = async () => {
|
const back = async () => {
|
||||||
onBack({
|
onBack({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
|
||||||
score: {correct: 0, total: 100, missing: 0},
|
score: { correct: 0, total: 100, missing: 0 },
|
||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNoteWriting = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const newText = e.target.value;
|
||||||
|
const words = newText.match(/\S+/g);
|
||||||
|
const wordCount = words ? words.length : 0;
|
||||||
|
|
||||||
|
if (wordCount <= 100) {
|
||||||
|
setInputText(newText);
|
||||||
|
} else {
|
||||||
|
let count = 0;
|
||||||
|
let lastIndex = 0;
|
||||||
|
const matches = newText.matchAll(/\S+/g);
|
||||||
|
for (const match of matches) {
|
||||||
|
count++;
|
||||||
|
if (count > 100) break;
|
||||||
|
lastIndex = match.index! + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInputText(newText.slice(0, lastIndex));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full gap-9">
|
<div className="flex flex-col h-full w-full gap-9">
|
||||||
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
|
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
|
||||||
@@ -112,7 +134,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
|||||||
<div className="flex flex-col gap-0">
|
<div className="flex flex-col gap-0">
|
||||||
<span className="font-semibold">{title}</span>
|
<span className="font-semibold">{title}</span>
|
||||||
{prompts.length > 0 && (
|
{prompts.length > 0 && (
|
||||||
<span className="font-semibold">You should talk for at least 30 seconds for your answer to be valid.</span>
|
<span className="font-semibold">You should talk for at least 1 minute and 30 seconds for your answer to be valid.</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!video_url && (
|
{!video_url && (
|
||||||
@@ -138,10 +160,24 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{prompts && prompts.length > 0 && (
|
||||||
|
<div className="w-full h-full flex flex-col gap-4">
|
||||||
|
<textarea
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
className="w-full h-full min-h-[200px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
|
||||||
|
onChange={handleNoteWriting}
|
||||||
|
value={inputText}
|
||||||
|
placeholder="Write your notes here..."
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<span className="text-base self-end text-mti-gray-cool">Word Count: {(inputText.match(/\S+/g) || []).length}/100</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ReactMediaRecorder
|
<ReactMediaRecorder
|
||||||
audio
|
audio
|
||||||
onStop={(blob) => setMediaBlob(blob)}
|
onStop={(blob) => setMediaBlob(blob)}
|
||||||
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
|
render={({ status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl }) => (
|
||||||
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
|
||||||
<p className="text-base font-normal">Record your answer:</p>
|
<p className="text-base font-normal">Record your answer:</p>
|
||||||
<div className="flex gap-8 items-center justify-center py-8">
|
<div className="flex gap-8 items-center justify-center py-8">
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -11,14 +11,15 @@ interface Props {
|
|||||||
className?: string;
|
className?: string;
|
||||||
navDisabled?: boolean;
|
navDisabled?: boolean;
|
||||||
focusMode?: boolean;
|
focusMode?: boolean;
|
||||||
|
bgColor?: string;
|
||||||
onFocusLayerMouseEnter?: () => void;
|
onFocusLayerMouseEnter?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layout({user, children, className, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
export default function Layout({user, children, className, bgColor="bg-white", navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative">
|
<main className={clsx("w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative")}>
|
||||||
<Navbar
|
<Navbar
|
||||||
path={router.pathname}
|
path={router.pathname}
|
||||||
user={user}
|
user={user}
|
||||||
@@ -37,7 +38,8 @@ export default function Layout({user, children, className, navDisabled = false,
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-full min-h-full h-fit md:mr-8 bg-white shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2",
|
`w-full min-h-full md:mr-8 ${bgColor} shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2`,
|
||||||
|
bgColor !== "bg-white" ? "justify-center" : "h-fit",
|
||||||
className,
|
className,
|
||||||
)}>
|
)}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,255 +1,179 @@
|
|||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {
|
import {Ticket, TicketStatus, TicketStatusLabel, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
|
||||||
Ticket,
|
import {User} from "@/interfaces/user";
|
||||||
TicketStatus,
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
TicketStatusLabel,
|
|
||||||
TicketType,
|
|
||||||
TicketTypeLabel,
|
|
||||||
} from "@/interfaces/ticket";
|
|
||||||
import { User } from "@/interfaces/user";
|
|
||||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { useState } from "react";
|
import {useState} from "react";
|
||||||
import { toast } from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import Input from "../Low/Input";
|
import Input from "../Low/Input";
|
||||||
import Select from "../Low/Select";
|
import Select from "../Low/Select";
|
||||||
import { checkAccess } from "@/utils/permissions";
|
import {checkAccess} from "@/utils/permissions";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
ticket: Ticket;
|
ticket: Ticket;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TicketDisplay({ user, ticket, onClose }: Props) {
|
export default function TicketDisplay({user, ticket, onClose}: Props) {
|
||||||
const [subject] = useState(ticket.subject);
|
const [subject] = useState(ticket.subject);
|
||||||
const [type, setType] = useState<TicketType>(ticket.type);
|
const [type, setType] = useState<TicketType>(ticket.type);
|
||||||
const [description] = useState(ticket.description);
|
const [description] = useState(ticket.description);
|
||||||
const [reporter] = useState(ticket.reporter);
|
const [reporter] = useState(ticket.reporter);
|
||||||
const [reportedFrom] = useState(ticket.reportedFrom);
|
const [reportedFrom] = useState(ticket.reportedFrom);
|
||||||
const [status, setStatus] = useState(ticket.status);
|
const [status, setStatus] = useState(ticket.status);
|
||||||
const [assignedTo, setAssignedTo] = useState<string | null>(
|
const [assignedTo, setAssignedTo] = useState<string | null>(ticket.assignedTo || null);
|
||||||
ticket.assignedTo || null,
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const { users } = useUsers();
|
const {users} = useUsers();
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
if (!type)
|
if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"});
|
||||||
return toast.error("Please choose a type!", { toastId: "missing-type" });
|
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.patch(`/api/tickets/${ticket.id}`, {
|
.patch(`/api/tickets/${ticket.id}`, {
|
||||||
subject,
|
subject,
|
||||||
type,
|
type,
|
||||||
description,
|
description,
|
||||||
reporter,
|
reporter,
|
||||||
reportedFrom,
|
reportedFrom,
|
||||||
status,
|
status,
|
||||||
assignedTo,
|
assignedTo,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`The ticket has been updated!`, { toastId: "submitted" });
|
toast.success(`The ticket has been updated!`, {toastId: "submitted"});
|
||||||
onClose();
|
onClose();
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
toast.error("Something went wrong, please try again later!", {
|
toast.error("Something went wrong, please try again later!", {
|
||||||
toastId: "error",
|
toastId: "error",
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const del = () => {
|
const del = () => {
|
||||||
if (!confirm("Are you sure you want to delete this ticket?")) return;
|
if (!confirm("Are you sure you want to delete this ticket?")) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.delete(`/api/tickets/${ticket.id}`)
|
.delete(`/api/tickets/${ticket.id}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`The ticket has been deleted!`, { toastId: "submitted" });
|
toast.success(`The ticket has been deleted!`, {toastId: "submitted"});
|
||||||
onClose();
|
onClose();
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
toast.error("Something went wrong, please try again later!", {
|
toast.error("Something went wrong, please try again later!", {
|
||||||
toastId: "error",
|
toastId: "error",
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="flex flex-col gap-4 pt-8">
|
<form className="flex flex-col gap-4 pt-8">
|
||||||
<Input
|
<Input label="Subject" type="text" name="subject" placeholder="Subject..." value={subject} onChange={(e) => null} disabled />
|
||||||
label="Subject"
|
|
||||||
type="text"
|
|
||||||
name="subject"
|
|
||||||
placeholder="Subject..."
|
|
||||||
value={subject}
|
|
||||||
onChange={(e) => null}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex w-full flex-col gap-3">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">
|
<label className="text-mti-gray-dim text-base font-normal">Status</label>
|
||||||
Status
|
<Select
|
||||||
</label>
|
options={Object.keys(TicketStatusLabel).map((x) => ({
|
||||||
<Select
|
value: x,
|
||||||
options={Object.keys(TicketStatusLabel).map((x) => ({
|
label: TicketStatusLabel[x as keyof typeof TicketStatusLabel],
|
||||||
value: x,
|
}))}
|
||||||
label: TicketStatusLabel[x as keyof typeof TicketStatusLabel],
|
value={{value: status, label: TicketStatusLabel[status]}}
|
||||||
}))}
|
onChange={(value) => setStatus((value?.value as TicketStatus) ?? undefined)}
|
||||||
value={{ value: status, label: TicketStatusLabel[status] }}
|
placeholder="Status..."
|
||||||
onChange={(value) =>
|
/>
|
||||||
setStatus((value?.value as TicketStatus) ?? undefined)
|
</div>
|
||||||
}
|
<div className="flex w-full flex-col gap-3">
|
||||||
placeholder="Status..."
|
<label className="text-mti-gray-dim text-base font-normal">Type</label>
|
||||||
/>
|
<Select
|
||||||
</div>
|
options={Object.keys(TicketTypeLabel).map((x) => ({
|
||||||
<div className="flex w-full flex-col gap-3">
|
value: x,
|
||||||
<label className="text-mti-gray-dim text-base font-normal">
|
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
|
||||||
Type
|
}))}
|
||||||
</label>
|
value={{value: type, label: TicketTypeLabel[type]}}
|
||||||
<Select
|
onChange={(value) => setType(value!.value as TicketType)}
|
||||||
options={Object.keys(TicketTypeLabel).map((x) => ({
|
placeholder="Type..."
|
||||||
value: x,
|
/>
|
||||||
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
|
</div>
|
||||||
}))}
|
</div>
|
||||||
value={{ value: type, label: TicketTypeLabel[type] }}
|
|
||||||
onChange={(value) => setType(value!.value as TicketType)}
|
|
||||||
placeholder="Type..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex w-full flex-col gap-3">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">
|
<label className="text-mti-gray-dim text-base font-normal">Assignee</label>
|
||||||
Assignee
|
<Select
|
||||||
</label>
|
options={[
|
||||||
<Select
|
{value: "me", label: "Assign to me"},
|
||||||
options={[
|
...users
|
||||||
{ value: "me", label: "Assign to me" },
|
.filter((x) => checkAccess(x, ["admin", "developer", "agent"]))
|
||||||
...users
|
.map((u) => ({
|
||||||
.filter((x) => checkAccess(x, ["admin", "developer", "agent"]))
|
value: u.id,
|
||||||
.map((u) => ({
|
label: `${u.name} - ${u.email}`,
|
||||||
value: u.id,
|
})),
|
||||||
label: `${u.name} - ${u.email}`,
|
]}
|
||||||
})),
|
disabled={checkAccess(user, ["agent"])}
|
||||||
]}
|
value={
|
||||||
disabled={checkAccess(user, ["agent"])}
|
assignedTo
|
||||||
value={
|
? {
|
||||||
assignedTo
|
value: assignedTo,
|
||||||
? {
|
label: `${users.find((u) => u.id === assignedTo)?.name} - ${users.find((u) => u.id === assignedTo)?.email}`,
|
||||||
value: assignedTo,
|
}
|
||||||
label: `${users.find((u) => u.id === assignedTo)?.name} - ${users.find((u) => u.id === assignedTo)?.email}`,
|
: null
|
||||||
}
|
}
|
||||||
: null
|
onChange={(value) => (value ? setAssignedTo(value.value === "me" ? user.id : value.value) : setAssignedTo(null))}
|
||||||
}
|
placeholder="Assignee..."
|
||||||
onChange={(value) =>
|
isClearable
|
||||||
value
|
/>
|
||||||
? setAssignedTo(value.value === "me" ? user.id : value.value)
|
</div>
|
||||||
: setAssignedTo(null)
|
|
||||||
}
|
|
||||||
placeholder="Assignee..."
|
|
||||||
isClearable
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||||
<Input
|
<Input label="Reported From" type="text" name="reportedFrom" onChange={() => null} value={reportedFrom} disabled />
|
||||||
label="Reported From"
|
<Input label="Date" type="text" name="date" onChange={() => null} value={moment(ticket.date).format("DD/MM/YYYY - HH:mm")} disabled />
|
||||||
type="text"
|
</div>
|
||||||
name="reportedFrom"
|
|
||||||
onChange={() => null}
|
|
||||||
value={reportedFrom}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Date"
|
|
||||||
type="text"
|
|
||||||
name="date"
|
|
||||||
onChange={() => null}
|
|
||||||
value={moment(ticket.date).format("DD/MM/YYYY - HH:mm")}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="-md:flex-col flex w-full items-center gap-4">
|
<div className="-md:flex-col flex w-full items-center gap-4">
|
||||||
<Input
|
<Input label="Reporter's Name" type="text" name="reporter" onChange={() => null} value={reporter.name} disabled />
|
||||||
label="Reporter's Name"
|
<Input label="Reporter's E-mail" type="text" name="reporter" onChange={() => null} value={reporter.email} disabled />
|
||||||
type="text"
|
<Input
|
||||||
name="reporter"
|
label="Reporter's Type"
|
||||||
onChange={() => null}
|
type="text"
|
||||||
value={reporter.name}
|
name="reporterType"
|
||||||
disabled
|
onChange={() => null}
|
||||||
/>
|
value={USER_TYPE_LABELS[reporter.type]}
|
||||||
<Input
|
disabled
|
||||||
label="Reporter's E-mail"
|
/>
|
||||||
type="text"
|
</div>
|
||||||
name="reporter"
|
|
||||||
onChange={() => null}
|
|
||||||
value={reporter.email}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Reporter's Type"
|
|
||||||
type="text"
|
|
||||||
name="reporterType"
|
|
||||||
onChange={() => null}
|
|
||||||
value={USER_TYPE_LABELS[reporter.type]}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
|
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
|
||||||
placeholder="Write your ticket's description here..."
|
placeholder="Write your ticket's description here..."
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
value={description}
|
value={description}
|
||||||
spellCheck
|
spellCheck
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="-md:flex-col-reverse mt-2 flex w-full items-center justify-between gap-4">
|
<div className="-md:flex-col-reverse mt-2 flex w-full items-center justify-between gap-4">
|
||||||
<Button
|
<Button type="button" color="red" className="w-full md:max-w-[200px]" variant="outline" onClick={del} isLoading={isLoading}>
|
||||||
type="button"
|
Delete
|
||||||
color="red"
|
</Button>
|
||||||
className="w-full md:max-w-[200px]"
|
|
||||||
variant="outline"
|
|
||||||
onClick={del}
|
|
||||||
isLoading={isLoading}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="-md:flex-col-reverse flex w-full items-center justify-end gap-4">
|
<div className="-md:flex-col-reverse flex w-full items-center justify-end gap-4">
|
||||||
<Button
|
<Button type="button" color="red" className="w-full md:max-w-[200px]" variant="outline" onClick={onClose} isLoading={isLoading}>
|
||||||
type="button"
|
Cancel
|
||||||
color="red"
|
</Button>
|
||||||
className="w-full md:max-w-[200px]"
|
<Button type="button" className="w-full md:max-w-[200px]" isLoading={isLoading} onClick={submit}>
|
||||||
variant="outline"
|
Update
|
||||||
onClick={onClose}
|
</Button>
|
||||||
isLoading={isLoading}
|
</div>
|
||||||
>
|
</div>
|
||||||
Cancel
|
</form>
|
||||||
</Button>
|
);
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="w-full md:max-w-[200px]"
|
|
||||||
isLoading={isLoading}
|
|
||||||
onClick={submit}
|
|
||||||
>
|
|
||||||
Update
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/components/HighlightContent.tsx
Normal file
39
src/components/HighlightContent.tsx
Normal 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;
|
||||||
168
src/components/InfiniteCarousel.tsx
Normal file
168
src/components/InfiniteCarousel.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import React, { useRef, useEffect, useState, useCallback, ReactNode } from 'react';
|
||||||
|
import { useSpring, animated } from '@react-spring/web';
|
||||||
|
import { useDrag } from '@use-gesture/react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
interface InfiniteCarouselProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
height: string;
|
||||||
|
speed?: number;
|
||||||
|
gap?: number;
|
||||||
|
overlay?: ReactNode;
|
||||||
|
overlayFunc?: (index: number) => void;
|
||||||
|
overlayClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InfiniteCarousel: React.FC<InfiniteCarouselProps> = ({
|
||||||
|
children,
|
||||||
|
height,
|
||||||
|
speed = 20000,
|
||||||
|
gap = 16,
|
||||||
|
overlay = undefined,
|
||||||
|
overlayFunc = undefined,
|
||||||
|
overlayClassName = ""
|
||||||
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [containerWidth, setContainerWidth] = useState<number>(0);
|
||||||
|
const itemCount = React.Children.count(children);
|
||||||
|
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||||
|
const [itemWidth, setItemWidth] = useState<number>(0);
|
||||||
|
const [isInfinite, setIsInfinite] = useState<boolean>(true);
|
||||||
|
const dragStartX = useRef<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const containerWidth = containerRef.current.clientWidth;
|
||||||
|
setContainerWidth(containerWidth);
|
||||||
|
|
||||||
|
const firstChild = containerRef.current.firstElementChild?.firstElementChild as HTMLElement;
|
||||||
|
if (firstChild) {
|
||||||
|
const childWidth = firstChild.offsetWidth;
|
||||||
|
setItemWidth(childWidth);
|
||||||
|
|
||||||
|
const totalContentWidth = (childWidth + gap) * itemCount - gap;
|
||||||
|
setIsInfinite(totalContentWidth > containerWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleResize();
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
}, [gap, itemCount]);
|
||||||
|
|
||||||
|
const totalWidth = (itemWidth + gap) * itemCount;
|
||||||
|
|
||||||
|
const [{ x }, api] = useSpring(() => ({
|
||||||
|
from: { x: 0 },
|
||||||
|
to: { x: -totalWidth },
|
||||||
|
config: { duration: speed },
|
||||||
|
loop: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const startAnimation = useCallback(() => {
|
||||||
|
if (isInfinite) {
|
||||||
|
api.start({
|
||||||
|
from: { x: x.get() },
|
||||||
|
to: { x: x.get() - totalWidth },
|
||||||
|
config: { duration: speed },
|
||||||
|
loop: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
api.stop();
|
||||||
|
api.start({ x: 0, immediate: true });
|
||||||
|
}
|
||||||
|
}, [api, x, totalWidth, speed, isInfinite]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerWidth > 0 && !isDragging) {
|
||||||
|
startAnimation();
|
||||||
|
}
|
||||||
|
}, [containerWidth, isDragging, startAnimation]);
|
||||||
|
|
||||||
|
const bind = useDrag(({ down, movement: [mx], first }) => {
|
||||||
|
if (!isInfinite) return;
|
||||||
|
if (first) {
|
||||||
|
setIsDragging(true);
|
||||||
|
api.stop();
|
||||||
|
dragStartX.current = x.get();
|
||||||
|
}
|
||||||
|
if (down) {
|
||||||
|
let newX = dragStartX.current + mx;
|
||||||
|
newX = ((newX % totalWidth) + totalWidth) % totalWidth;
|
||||||
|
if (newX > 0) newX -= totalWidth;
|
||||||
|
api.start({ x: newX, immediate: true });
|
||||||
|
} else {
|
||||||
|
setIsDragging(false);
|
||||||
|
startAnimation();
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
filterTaps: true,
|
||||||
|
from: () => [x.get(), 0],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="overflow-hidden relative select-none"
|
||||||
|
style={{ height, touchAction: 'pan-y' }}
|
||||||
|
ref={containerRef}
|
||||||
|
{...(isInfinite ? bind() : {})}
|
||||||
|
>
|
||||||
|
<animated.div
|
||||||
|
className="flex"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
willChange: 'transform',
|
||||||
|
transform: isInfinite
|
||||||
|
? x.to((x) => `translate3d(${x}px, 0, 0)`)
|
||||||
|
: 'none',
|
||||||
|
gap: `${gap}px`,
|
||||||
|
width: 'fit-content',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{React.Children.map(children, (child, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex-shrink-0 relative"
|
||||||
|
>
|
||||||
|
{overlay !== undefined && overlayFunc !== undefined && (
|
||||||
|
<div className={clsx('absolute', overlayClassName)} onClick={() => overlayFunc(i)}>
|
||||||
|
{overlay}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="select-none"
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
{child}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isInfinite && React.Children.map(children, (child, i) => (
|
||||||
|
<div
|
||||||
|
key={`clone-${i}`}
|
||||||
|
className="flex-shrink-0 relative"
|
||||||
|
>
|
||||||
|
{overlay !== undefined && overlayFunc !== undefined && (
|
||||||
|
<div className={clsx('absolute', overlayClassName)} onClick={() => overlayFunc(i)}>
|
||||||
|
{overlay}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="select-none"
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
{child}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</animated.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InfiniteCarousel;
|
||||||
51
src/components/List.tsx
Normal file
51
src/components/List.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {ReactNode, useEffect, useState} from "react";
|
|||||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
|
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
|
||||||
import ProgressBar from "../Low/ProgressBar";
|
import ProgressBar from "../Low/ProgressBar";
|
||||||
import TimerEndedModal from "../TimerEndedModal";
|
import TimerEndedModal from "../TimerEndedModal";
|
||||||
|
import Timer from "./Timer";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
@@ -15,35 +16,13 @@ interface Props {
|
|||||||
exerciseIndex: number;
|
exerciseIndex: number;
|
||||||
totalExercises: number;
|
totalExercises: number;
|
||||||
disableTimer?: boolean;
|
disableTimer?: boolean;
|
||||||
|
partLabel?: string;
|
||||||
|
showTimer?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ModuleTitle({minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false}: Props) {
|
export default function ModuleTitle({
|
||||||
const [timer, setTimer] = useState(minTimer * 60);
|
minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false, partLabel, showTimer = true
|
||||||
const [showModal, setShowModal] = useState(false);
|
}: Props) {
|
||||||
const [warningMode, setWarningMode] = useState(false);
|
|
||||||
|
|
||||||
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
|
||||||
const {timeSpent} = useExamStore((state) => state);
|
|
||||||
|
|
||||||
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!disableTimer) {
|
|
||||||
const timerInterval = setInterval(() => setTimer((prev) => prev - 1), 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(timerInterval);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [disableTimer, minTimer]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (timer <= 0) setShowModal(true);
|
|
||||||
}, [timer]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (timer < 300 && !warningMode) setWarningMode(true);
|
|
||||||
}, [timer, warningMode]);
|
|
||||||
|
|
||||||
const moduleIcon: {[key in Module]: ReactNode} = {
|
const moduleIcon: {[key in Module]: ReactNode} = {
|
||||||
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
|
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
|
||||||
@@ -55,49 +34,39 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TimerEndedModal
|
{showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />}
|
||||||
isOpen={showModal}
|
<div className="w-full">
|
||||||
onClose={() => {
|
{partLabel && (
|
||||||
setHasExamEnded(true);
|
<div className="text-3xl space-y-4">
|
||||||
setShowModal(false);
|
{partLabel.split("\n\n").map((line, index) => {
|
||||||
}}
|
if (index == 0)
|
||||||
/>
|
return (
|
||||||
<motion.div
|
<p key={index} className="font-bold">
|
||||||
className={clsx(
|
{line}
|
||||||
"absolute top-4 right-6 bg-mti-gray-seasalt px-4 py-3 flex items-center gap-2 rounded-full text-mti-gray-davy",
|
</p>
|
||||||
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt",
|
);
|
||||||
)}
|
else
|
||||||
initial={{scale: warningMode && !disableTimer ? 0.8 : 1}}
|
return (
|
||||||
animate={{scale: warningMode && !disableTimer ? 1.1 : 1}}
|
<p key={index} className="text-2xl font-semibold">
|
||||||
transition={{repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut"}}>
|
{line}
|
||||||
<BsStopwatch className="w-6 h-6" />
|
</p>
|
||||||
<span className="text-base font-semibold w-12">
|
);
|
||||||
{timer > 0 && (
|
})}
|
||||||
<>
|
</div>
|
||||||
{Math.floor(timer / 60)
|
)}
|
||||||
.toString(10)
|
<div className={clsx("flex gap-6 w-full h-fit items-center", partLabel ? "mt-10" : "mt-5")}>
|
||||||
.padStart(2, "0")}
|
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
|
||||||
:
|
<div className="flex flex-col gap-3 w-full">
|
||||||
{Math.floor(timer % 60)
|
<div className="w-full flex justify-between">
|
||||||
.toString(10)
|
<span className="text-base font-semibold">
|
||||||
.padStart(2, "0")}
|
{moduleLabels[module]} exam {label && `- ${label}`}
|
||||||
</>
|
</span>
|
||||||
)}
|
<span className="text-sm font-semibold self-end">
|
||||||
{timer <= 0 && <>00:00</>}
|
Question {exerciseIndex}/{totalExercises}
|
||||||
</span>
|
</span>
|
||||||
</motion.div>
|
</div>
|
||||||
<div className="flex gap-6 w-full h-fit items-center mt-5">
|
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
|
||||||
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
|
|
||||||
<div className="flex flex-col gap-3 w-full">
|
|
||||||
<div className="w-full flex justify-between">
|
|
||||||
<span className="text-base font-semibold">
|
|
||||||
{moduleLabels[module]} exam {label && `- ${label}`}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold self-end">
|
|
||||||
Exercise {exerciseIndex}/{totalExercises}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
80
src/components/Medium/Timer.tsx
Normal file
80
src/components/Medium/Timer.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import TimerEndedModal from "../TimerEndedModal";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { BsStopwatch } from "react-icons/bs";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
minTimer: number;
|
||||||
|
disableTimer?: boolean;
|
||||||
|
standalone?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Timer: React.FC<Props> = ({minTimer, disableTimer, standalone = false}) => {
|
||||||
|
const [timer, setTimer] = useState(minTimer * 60);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [warningMode, setWarningMode] = useState(false);
|
||||||
|
|
||||||
|
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
|
||||||
|
const { timeSpent } = useExamStore((state) => state);
|
||||||
|
|
||||||
|
useEffect(() => setTimer((prev) => prev - timeSpent), [timeSpent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!disableTimer) {
|
||||||
|
const timerInterval = setInterval(() => setTimer((prev) => prev - 1), 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(timerInterval);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [disableTimer, minTimer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timer <= 0) setShowModal(true);
|
||||||
|
}, [timer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timer < 300 && !warningMode) setWarningMode(true);
|
||||||
|
}, [timer, warningMode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TimerEndedModal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={() => {
|
||||||
|
setHasExamEnded(true);
|
||||||
|
setShowModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className={clsx(
|
||||||
|
"absolute right-6 bg-mti-gray-seasalt px-4 py-3 flex items-center gap-2 rounded-full text-mti-gray-davy",
|
||||||
|
standalone ? "top-6" : "top-4",
|
||||||
|
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt",
|
||||||
|
)}
|
||||||
|
initial={{ scale: warningMode && !disableTimer ? 0.8 : 1 }}
|
||||||
|
animate={{ scale: warningMode && !disableTimer ? 1.1 : 1 }}
|
||||||
|
transition={{ repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut" }}>
|
||||||
|
<BsStopwatch className="w-6 h-6" />
|
||||||
|
<span className="text-base font-semibold w-12">
|
||||||
|
{timer > 0 && (
|
||||||
|
<>
|
||||||
|
{Math.floor(timer / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(timer % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{timer <= 0 && <>00:00</>}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Timer;
|
||||||
24
src/components/ModuleBadge.tsx
Normal file
24
src/components/ModuleBadge.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
|
||||||
|
|
||||||
|
const ModuleBadge: React.FC<{ module: string; level?: number }> = ({ module, level }) => (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
|
||||||
|
module === "reading" && "bg-ielts-reading",
|
||||||
|
module === "listening" && "bg-ielts-listening",
|
||||||
|
module === "writing" && "bg-ielts-writing",
|
||||||
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
|
module === "level" && "bg-ielts-level",
|
||||||
|
)}>
|
||||||
|
{module === "reading" && <BsBook className="w-4 h-4" />}
|
||||||
|
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
||||||
|
{module === "writing" && <BsPen className="w-4 h-4" />}
|
||||||
|
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
||||||
|
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
||||||
|
{/* do not switch to level && it will convert the 0.0 to 0*/}
|
||||||
|
{level !== undefined && (<span className="text-sm">{level.toFixed(1)}</span>)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ModuleBadge;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import React from "react";
|
||||||
import {Permission} from "@/interfaces/permissions";
|
import {Permission} from "@/interfaces/permissions";
|
||||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable, Row} from "@tanstack/react-table";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {convertCamelCaseToReadable} from "@/utils/string";
|
import {convertCamelCaseToReadable} from "@/utils/string";
|
||||||
|
|
||||||
@@ -29,8 +30,18 @@ export default function PermissionList({permissions}: Props) {
|
|||||||
columns: defaultColumns,
|
columns: defaultColumns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const groupedData: {[key: string]: Row<Permission>[]} = table.getRowModel().rows.reduce((groups: {[key: string]: Row<Permission>[]}, row) => {
|
||||||
|
const parent = row.original.topic;
|
||||||
|
if (!groups[parent]) {
|
||||||
|
groups[parent] = [];
|
||||||
|
}
|
||||||
|
groups[parent].push(row);
|
||||||
|
return groups;
|
||||||
|
}, {});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full h-full">
|
||||||
<div className="w-full flex flex-col gap-2">
|
<div className="w-full flex flex-col gap-2">
|
||||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -45,14 +56,23 @@ export default function PermissionList({permissions}: Props) {
|
|||||||
))}
|
))}
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="px-2">
|
<tbody className="px-2">
|
||||||
{table.getRowModel().rows.map((row) => (
|
{Object.keys(groupedData).map((parent) => (
|
||||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
<React.Fragment key={parent}>
|
||||||
{row.getVisibleCells().map((cell) => (
|
<tr>
|
||||||
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
|
<td className="px-2 py-2 items-center w-fit">
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
<strong>{parent}</strong>
|
||||||
</td>
|
</td>
|
||||||
|
</tr>
|
||||||
|
{groupedData[parent].map((row, i) => (
|
||||||
|
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
80
src/components/QuestionsModal.tsx
Normal file
80
src/components/QuestionsModal.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
|
import { Fragment } from "react";
|
||||||
|
import Button from "./Low/Button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
blankQuestions?: boolean;
|
||||||
|
finishingWhat? : string;
|
||||||
|
onClose: (next?: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QuestionsModal({ isOpen, onClose, blankQuestions = true, finishingWhat = "module" }: Props) {
|
||||||
|
return (
|
||||||
|
<Transition show={isOpen} as={Fragment}>
|
||||||
|
<Dialog onClose={() => onClose(false)} className="relative z-50">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0">
|
||||||
|
<div className="fixed inset-0 bg-black/30" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95">
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
|
||||||
|
{blankQuestions ? (
|
||||||
|
<>
|
||||||
|
<Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title>
|
||||||
|
<span>
|
||||||
|
Please note that you are finishing the current {finishingWhat} and once you proceed to the next {finishingWhat}, you will no longer be
|
||||||
|
able to change the answers of the current one, including your unanswered questions. <br />
|
||||||
|
<br />
|
||||||
|
Are you sure you want to continue without completing those questions?
|
||||||
|
</span>
|
||||||
|
<div className="w-full flex justify-between mt-8">
|
||||||
|
<Button color="purple" onClick={() => onClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full">
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
): (
|
||||||
|
<>
|
||||||
|
<Dialog.Title className="font-bold text-xl">Confirm Submission</Dialog.Title>
|
||||||
|
<span>
|
||||||
|
Please note that you are finishing the current {finishingWhat} and once you proceed to the next {finishingWhat}, you will no longer be
|
||||||
|
able to review the answers of the current one. <br />
|
||||||
|
<br />
|
||||||
|
Are you sure you want to continue?
|
||||||
|
</span>
|
||||||
|
<div className="w-full flex justify-between mt-8">
|
||||||
|
<Button color="purple" onClick={() => onClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full">
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</div>
|
||||||
|
</Transition.Child>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
BsCurrencyDollar,
|
BsCurrencyDollar,
|
||||||
BsClipboardData,
|
BsClipboardData,
|
||||||
BsFileLock,
|
BsFileLock,
|
||||||
|
BsPeople,
|
||||||
} from "react-icons/bs";
|
} from "react-icons/bs";
|
||||||
|
import {CiDumbbell} from "react-icons/ci";
|
||||||
import {RiLogoutBoxFill} from "react-icons/ri";
|
import {RiLogoutBoxFill} from "react-icons/ri";
|
||||||
import {SlPencil} from "react-icons/sl";
|
import {SlPencil} from "react-icons/sl";
|
||||||
import {FaAward} from "react-icons/fa";
|
import {FaAward} from "react-icons/fa";
|
||||||
@@ -27,6 +29,7 @@ import usePreferencesStore from "@/stores/preferencesStore";
|
|||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import useTicketsListener from "@/hooks/useTicketsListener";
|
import useTicketsListener from "@/hooks/useTicketsListener";
|
||||||
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
||||||
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
interface Props {
|
interface Props {
|
||||||
path: string;
|
path: string;
|
||||||
navDisabled?: boolean;
|
navDisabled?: boolean;
|
||||||
@@ -79,6 +82,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
|
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
|
||||||
|
|
||||||
const {totalAssignedTickets} = useTicketsListener(user.id);
|
const {totalAssignedTickets} = useTicketsListener(user.id);
|
||||||
|
const {permissions} = usePermissions(user.id);
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
axios.post("/api/logout").finally(() => {
|
axios.post("/api/logout").finally(() => {
|
||||||
@@ -97,19 +101,25 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
)}>
|
)}>
|
||||||
<div className="-xl:hidden flex-col gap-3 xl:flex">
|
<div className="-xl:hidden flex-col gap-3 xl:flex">
|
||||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
|
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
|
||||||
{checkAccess(user, ["student", "teacher", "developer"], "viewExams") && (
|
{checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExams") && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={isMinimized} />
|
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={isMinimized} />
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, ["student", "teacher", "developer"], "viewExercises") && (
|
{checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExercises") && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={isMinimized} />
|
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={isMinimized} />
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, getTypesOfUser(["agent"]), "viewStats") && (
|
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
|
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && (
|
{checkAccess(user, ["developer", "admin", "teacher", "student"], permissions) && (
|
||||||
|
<Nav disabled={disableNavigation} Icon={BsPeople} label="Groups" path={path} keyPath="/groups" isMinimized={isMinimized} />
|
||||||
|
)}
|
||||||
|
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
|
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"], "viewPaymentRecords") && (
|
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
|
||||||
|
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={isMinimized} />
|
||||||
|
)}
|
||||||
|
{checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"], permissions, "viewPaymentRecords") && (
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
Icon={BsCurrencyDollar}
|
Icon={BsCurrencyDollar}
|
||||||
@@ -129,7 +139,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
isMinimized={isMinimized}
|
isMinimized={isMinimized}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, ["admin", "developer", "agent"], "viewTickets") && (
|
{checkAccess(user, ["admin", "developer", "agent"], permissions, "viewTickets") && (
|
||||||
<Nav
|
<Nav
|
||||||
disabled={disableNavigation}
|
disabled={disableNavigation}
|
||||||
Icon={BsClipboardData}
|
Icon={BsClipboardData}
|
||||||
@@ -140,37 +150,40 @@ 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">
|
||||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
|
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
|
||||||
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
|
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
|
||||||
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
|
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
|
||||||
{checkAccess(user, getTypesOfUser(["agent"]), "viewStats") && (
|
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
|
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && (
|
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
|
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
|
||||||
)}
|
)}
|
||||||
|
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
|
||||||
|
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={true} />
|
||||||
|
)}
|
||||||
{checkAccess(user, getTypesOfUser(["student"])) && (
|
{checkAccess(user, getTypesOfUser(["student"])) && (
|
||||||
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
|
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
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";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
|
||||||
export default function FillBlanksSolutions({
|
export default function FillBlanksSolutions({
|
||||||
id,
|
id,
|
||||||
@@ -12,43 +13,81 @@ export default function FillBlanksSolutions({
|
|||||||
solutions,
|
solutions,
|
||||||
words,
|
words,
|
||||||
text,
|
text,
|
||||||
userSolutions,
|
|
||||||
onNext,
|
onNext,
|
||||||
onBack,
|
onBack,
|
||||||
}: FillBlanksExercise & CommonProps) {
|
}: FillBlanksExercise & CommonProps) {
|
||||||
|
|
||||||
|
// next and back was all messed up and still don't know why, anyways
|
||||||
|
const storeUserSolutions = useExamStore((state) => state.userSolutions);
|
||||||
|
|
||||||
|
const correctUserSolutions = storeUserSolutions.find(
|
||||||
|
(solution) => solution.exercise === id
|
||||||
|
)?.solutions;
|
||||||
|
|
||||||
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 = correctUserSolutions!.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;
|
||||||
|
console.log(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.toString() === x.id.toString();
|
||||||
|
}
|
||||||
|
});
|
||||||
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 - correctUserSolutions!.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>
|
||||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||||
const id = match.replaceAll(/[\{\}]/g, "");
|
const id = match.replaceAll(/[\{\}]/g, "");
|
||||||
const userSolution = userSolutions.find((x) => x.id === id);
|
const userSolution = correctUserSolutions!.find((x) => x.id.toString() === id.toString());
|
||||||
const solution = solutions.find((x) => x.id === id)!;
|
const answerSolution = solutions.find(sol => sol.id.toString() === id.toString())!.solution;
|
||||||
|
|
||||||
if (!userSolution) {
|
if (!userSolution) {
|
||||||
|
let answerText;
|
||||||
|
if (typeCheckWordsMC(words)) {
|
||||||
|
const options = words.find((x) => x.id.toString() === id.toString());
|
||||||
|
const correctKey = Object.keys(options!.options).find(key =>
|
||||||
|
key.toLowerCase() === answerSolution.toLowerCase()
|
||||||
|
);
|
||||||
|
answerText = options!.options[correctKey as keyof typeof options];
|
||||||
|
} else {
|
||||||
|
answerText = answerSolution;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"rounded-full hover:text-white hover:bg-mti-gray-davy transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-gray-davy",
|
"rounded-full hover:text-white hover:bg-mti-gray-davy transition duration-300 ease-in-out my-1 px-5 py-2 text-center text-white bg-mti-gray-davy",
|
||||||
)}>
|
)}>
|
||||||
{solution?.solution}
|
{answerText}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -56,23 +95,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.toString() === id.toString());
|
||||||
|
if (options) {
|
||||||
|
const correctKey = Object.keys(options.options).find(key =>
|
||||||
|
key.toLowerCase() === answerSolution.toLowerCase()
|
||||||
|
);
|
||||||
|
correct = userSolution.solution == options.options[correctKey as keyof typeof options.options];
|
||||||
|
solutionText = options.options[correctKey as keyof typeof options.options] || answerSolution;
|
||||||
|
} else {
|
||||||
|
correct = false;
|
||||||
|
solutionText = answerSolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
correct = userSolutionText === answerSolution;
|
||||||
|
solutionText = answerSolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (correct) {
|
||||||
return (
|
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 +157,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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -101,16 +170,8 @@ export default function FillBlanksSolutions({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
<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">
|
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
||||||
{userSolutions &&
|
{correctUserSolutions &&
|
||||||
text.split("\\n").map((line, index) => (
|
text.split("\\n").map((line, index) => (
|
||||||
<p key={index}>
|
<p key={index}>
|
||||||
{renderLines(line)}
|
{renderLines(line)}
|
||||||
@@ -138,14 +199,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: correctUserSolutions!, 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: correctUserSolutions!, score: calculateScore(), type })}
|
||||||
className="max-w-[200px] self-end w-full">
|
className="max-w-[200px] self-end w-full">
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {InteractiveSpeakingExercise} from "@/interfaces/exam";
|
import { InteractiveSpeakingExercise } from "@/interfaces/exam";
|
||||||
import {CommonProps} from ".";
|
import { CommonProps } from ".";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {speakingReverseMarking} from "@/utils/score";
|
import { speakingReverseMarking } from "@/utils/score";
|
||||||
import {Tab} from "@headlessui/react";
|
import { Tab } from "@headlessui/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Modal from "../Modal";
|
import Modal from "../Modal";
|
||||||
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
import ReactDiffViewer, { DiffMethod } from "react-diff-viewer";
|
||||||
|
|
||||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
|
||||||
|
|
||||||
export default function InteractiveSpeaking({
|
export default function InteractiveSpeaking({
|
||||||
id,
|
id,
|
||||||
@@ -24,15 +24,22 @@ export default function InteractiveSpeaking({
|
|||||||
onBack,
|
onBack,
|
||||||
}: InteractiveSpeakingExercise & CommonProps) {
|
}: InteractiveSpeakingExercise & CommonProps) {
|
||||||
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
|
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
|
||||||
const [diffNumber, setDiffNumber] = useState<0 | 1 | 2 | 3>(0);
|
const [diffNumber, setDiffNumber] = useState(0);
|
||||||
|
|
||||||
|
const tooltips: { [key: string]: string } = {
|
||||||
|
"Grammatical Range and Accuracy": "Assesses the variety and correctness of grammatical structures used. A higher score indicates a wide range of complex and accurate grammar; a lower score suggests the need for more basic grammar practice.",
|
||||||
|
"Fluency and Coherence": "Evaluates smoothness and logical flow of speech. A higher score means natural, effortless speech and clear idea progression; a lower score indicates frequent pauses and difficulty in maintaining coherence.",
|
||||||
|
"Pronunciation": "Measures clarity and accuracy of spoken words. A higher score reflects clear, well-articulated speech with correct intonation; a lower score shows challenges in being understood.",
|
||||||
|
"Lexical Resource": "Looks at the range and appropriateness of vocabulary. A higher score demonstrates a rich and precise vocabulary; a lower score suggests limited vocabulary usage and appropriateness.",
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
|
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
|
||||||
Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, {path: x.answer}, {responseType: "arraybuffer"}))).then(
|
Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, { path: x.answer }, { responseType: "arraybuffer" }))).then(
|
||||||
(values) => {
|
(values) => {
|
||||||
setSolutionsURL(
|
setSolutionsURL(
|
||||||
values.map(({data}) => {
|
values.map(({ data }) => {
|
||||||
const blob = new Blob([data], {type: "audio/wav"});
|
const blob = new Blob([data], { type: "audio/wav" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
return url;
|
return url;
|
||||||
@@ -64,13 +71,13 @@ export default function InteractiveSpeaking({
|
|||||||
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
|
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
|
||||||
padding: "32px 28px",
|
padding: "32px 28px",
|
||||||
},
|
},
|
||||||
marker: {display: "none"},
|
marker: { display: "none" },
|
||||||
diffRemoved: {padding: "32px 28px"},
|
diffRemoved: { padding: "32px 28px" },
|
||||||
diffAdded: {padding: "32px 28px"},
|
diffAdded: { padding: "32px 28px" },
|
||||||
|
|
||||||
wordRemoved: {padding: "0px", display: "initial"},
|
wordRemoved: { padding: "0px", display: "initial" },
|
||||||
wordAdded: {padding: "0px", display: "initial"},
|
wordAdded: { padding: "0px", display: "initial" },
|
||||||
wordDiff: {padding: "0px", display: "initial"},
|
wordDiff: { padding: "0px", display: "initial" },
|
||||||
}}
|
}}
|
||||||
oldValue={userSolutions[0].evaluation[`transcript_${diffNumber}`]?.replaceAll("\\n", "\n")}
|
oldValue={userSolutions[0].evaluation[`transcript_${diffNumber}`]?.replaceAll("\\n", "\n")}
|
||||||
newValue={userSolutions[0].evaluation[`fixed_text_${diffNumber}`]?.replaceAll("\\n", "\n")}
|
newValue={userSolutions[0].evaluation[`fixed_text_${diffNumber}`]?.replaceAll("\\n", "\n")}
|
||||||
@@ -115,13 +122,13 @@ export default function InteractiveSpeaking({
|
|||||||
{userSolutions &&
|
{userSolutions &&
|
||||||
userSolutions.length > 0 &&
|
userSolutions.length > 0 &&
|
||||||
userSolutions[0].evaluation &&
|
userSolutions[0].evaluation &&
|
||||||
userSolutions[0].evaluation[`transcript_${(index + 1) as 1 | 2 | 3}`] &&
|
userSolutions[0].evaluation[`transcript_${(index + 1)}`] &&
|
||||||
userSolutions[0].evaluation[`fixed_text_${(index + 1) as 1 | 2 | 3}`] && (
|
userSolutions[0].evaluation[`fixed_text_${(index + 1)}`] && (
|
||||||
<Button
|
<Button
|
||||||
className="w-full max-w-[180px] !py-2 self-center"
|
className="w-full max-w-[180px] !py-2 self-center"
|
||||||
color="pink"
|
color="pink"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setDiffNumber((index + 1) as 1 | 2 | 3)}>
|
onClick={() => setDiffNumber((index + 1))}>
|
||||||
View Correction
|
View Correction
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -132,23 +139,36 @@ export default function InteractiveSpeaking({
|
|||||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<div className="flex gap-4 px-1">
|
<div className="flex gap-4 px-1">
|
||||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key, index) => {
|
||||||
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
||||||
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2")} key={key}>
|
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
|
||||||
|
index === 0 && "tooltip-right"
|
||||||
|
)} key={key} data-tip={tooltips[key] || "No additional information available"}>
|
||||||
{key}: Level {grade}
|
{key}: Level {grade}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{userSolutions[0].evaluation &&
|
{userSolutions[0].evaluation &&
|
||||||
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length === 3 ? (
|
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length > 0 ? (
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
||||||
<Tab
|
<Tab
|
||||||
className={({selected}) =>
|
className={({ selected }) =>
|
||||||
|
clsx(
|
||||||
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||||
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
General Feedback
|
||||||
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
className={({ selected }) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
@@ -158,70 +178,22 @@ export default function InteractiveSpeaking({
|
|||||||
}>
|
}>
|
||||||
Evaluation
|
Evaluation
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
{Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).map((key, index) => (
|
||||||
className={({selected}) =>
|
<Tab
|
||||||
clsx(
|
key={key}
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
className={({ selected }) =>
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
clsx(
|
||||||
"transition duration-300 ease-in-out",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
)
|
"transition duration-300 ease-in-out",
|
||||||
}>
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||||
Recommended Answer (Prompt 1)
|
)
|
||||||
</Tab>
|
}>
|
||||||
<Tab
|
Recommended Answer<br />(Prompt {index + 1})
|
||||||
className={({selected}) =>
|
</Tab>
|
||||||
clsx(
|
))}
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
Recommended Answer (Prompt 2)
|
|
||||||
</Tab>
|
|
||||||
<Tab
|
|
||||||
className={({selected}) =>
|
|
||||||
clsx(
|
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
Recommended Answer (Prompt 3)
|
|
||||||
</Tab>
|
|
||||||
<Tab
|
|
||||||
className={({selected}) =>
|
|
||||||
clsx(
|
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
Global Overview
|
|
||||||
</Tab>
|
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels>
|
<Tab.Panels>
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
|
||||||
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
|
||||||
</Tab.Panel>
|
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
|
||||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
|
||||||
{userSolutions[0].evaluation!.perfect_answer_1!.answer.replaceAll(/\s{2,}/g, "\n\n")}
|
|
||||||
</span>
|
|
||||||
</Tab.Panel>
|
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
|
||||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
|
||||||
{userSolutions[0].evaluation!.perfect_answer_2!.answer.replaceAll(/\s{2,}/g, "\n\n")}
|
|
||||||
</span>
|
|
||||||
</Tab.Panel>
|
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
|
||||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
|
||||||
{userSolutions[0].evaluation!.perfect_answer_3!.answer.replaceAll(/\s{2,}/g, "\n\n")}
|
|
||||||
</span>
|
|
||||||
</Tab.Panel>
|
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
||||||
@@ -230,15 +202,25 @@ export default function InteractiveSpeaking({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} className="flex flex-col gap-2">
|
<div key={key} className="flex flex-col gap-2">
|
||||||
<span className={"font-semibold"}>
|
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit")} key={key}>
|
||||||
{key}: Level {grade}
|
{key}: Level {grade}
|
||||||
</span>
|
</div>
|
||||||
{typeof taskResponse !== "number" && <span>{taskResponse.comment}</span>}
|
{typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
||||||
|
</Tab.Panel>
|
||||||
|
{Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).map((key, index) => (
|
||||||
|
<Tab.Panel key={key} className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||||
|
{userSolutions[0].evaluation![`perfect_answer_${(index + 1)}`].answer.replaceAll(/\s{2,}/g, "\n\n")}
|
||||||
|
</span>
|
||||||
|
</Tab.Panel>
|
||||||
|
))}
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
) : (
|
) : (
|
||||||
@@ -259,7 +241,7 @@ export default function InteractiveSpeaking({
|
|||||||
onBack({
|
onBack({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: userSolutions,
|
solutions: userSolutions,
|
||||||
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
score: { total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 },
|
||||||
type,
|
type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,20 +14,51 @@ 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 = questionShuffleMap ? getShuffledOptions(options as { id: string, text: string }[], questionShuffleMap) : options;
|
||||||
|
const newSolution = 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>.*?<\/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;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
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";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,36 +66,36 @@ function Question({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{isNaN(Number(id)) ? (
|
{isNaN(Number(id)) ? (
|
||||||
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
<span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||||
) : (
|
) : (
|
||||||
<span className="">
|
<span className="text-lg">
|
||||||
<>
|
<>
|
||||||
{id} - <span>{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
{id} - <span className="text-lg">{renderPrompt(prompt).filter((x) => x?.toString() !== "<u>")} </span>
|
||||||
</>
|
</>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-4 gap-4 place-items-center">
|
<div className="flex flex-wrap gap-4 justify-between">
|
||||||
{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 select-none",
|
||||||
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-base select-none", 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 +103,9 @@ 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, partIndex, exam } = useExamStore((state) => state);
|
||||||
|
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = questions.length;
|
const total = questions.length;
|
||||||
@@ -82,12 +114,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 +127,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);
|
||||||
}
|
}
|
||||||
@@ -105,7 +137,7 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4 w-full h-full mb-20">
|
<div className="flex flex-col gap-4 w-full h-full mb-20">
|
||||||
<div className="flex flex-col gap-2 mt-4 h-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
<div className="flex flex-col gap-2 mt-4 h-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
<span className="text-xl font-semibold">{prompt}</span>
|
{/*<span className="text-xl font-semibold">{prompt}</span>*/}
|
||||||
{userSolutions && questionIndex < questions.length && (
|
{userSolutions && questionIndex < questions.length && (
|
||||||
<Question
|
<Question
|
||||||
{...questions[questionIndex]}
|
{...questions[questionIndex]}
|
||||||
@@ -127,10 +159,14 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
|
|||||||
Wrong
|
Wrong
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full">
|
<Button color="purple" variant="outline" onClick={back} className="max-w-[200px] w-full"
|
||||||
|
disabled={
|
||||||
|
exam && typeof partIndex !== "undefined" && exam.module === "level" &&
|
||||||
|
typeof exam.parts[0].intro === "string" && questionIndex === 0 && partIndex === 0}
|
||||||
|
>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {SpeakingExercise} from "@/interfaces/exam";
|
import { SpeakingExercise } from "@/interfaces/exam";
|
||||||
import {CommonProps} from ".";
|
import { CommonProps } from ".";
|
||||||
import {Fragment, useEffect, useState} from "react";
|
import { Fragment, useEffect, useState } from "react";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {speakingReverseMarking} from "@/utils/score";
|
import { speakingReverseMarking } from "@/utils/score";
|
||||||
import {Tab} from "@headlessui/react";
|
import { Tab } from "@headlessui/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Modal from "../Modal";
|
import Modal from "../Modal";
|
||||||
import {BsQuestionCircleFill} from "react-icons/bs";
|
import { BsQuestionCircleFill } from "react-icons/bs";
|
||||||
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
import ReactDiffViewer, { DiffMethod } from "react-diff-viewer";
|
||||||
|
|
||||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
|
||||||
|
|
||||||
export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
|
export default function Speaking({ id, type, title, video_url, text, prompts, userSolutions, onNext, onBack }: SpeakingExercise & CommonProps) {
|
||||||
const [solutionURL, setSolutionURL] = useState<string>();
|
const [solutionURL, setSolutionURL] = useState<string>();
|
||||||
const [showDiff, setShowDiff] = useState(false);
|
const [showDiff, setShowDiff] = useState(false);
|
||||||
|
|
||||||
@@ -23,8 +23,8 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
const solution = userSolutions[0].solution;
|
const solution = userSolutions[0].solution;
|
||||||
|
|
||||||
if (solution.startsWith("https://")) return setSolutionURL(solution);
|
if (solution.startsWith("https://")) return setSolutionURL(solution);
|
||||||
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
|
axios.post(`/api/speaking`, { path: userSolutions[0].solution }, { responseType: "arraybuffer" }).then(({ data }) => {
|
||||||
const blob = new Blob([data], {type: "audio/wav"});
|
const blob = new Blob([data], { type: "audio/wav" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
setSolutionURL(url);
|
setSolutionURL(url);
|
||||||
@@ -32,6 +32,13 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
}
|
}
|
||||||
}, [userSolutions]);
|
}, [userSolutions]);
|
||||||
|
|
||||||
|
const tooltips: { [key: string]: string } = {
|
||||||
|
"Grammatical Range and Accuracy": "Assesses the variety and correctness of grammatical structures used. A higher score indicates a wide range of complex and accurate grammar; a lower score suggests the need for more basic grammar practice.",
|
||||||
|
"Fluency and Coherence": "Evaluates smoothness and logical flow of speech. A higher score means natural, effortless speech and clear idea progression; a lower score indicates frequent pauses and difficulty in maintaining coherence.",
|
||||||
|
"Pronunciation": "Measures clarity and accuracy of spoken words. A higher score reflects clear, well-articulated speech with correct intonation; a lower score shows challenges in being understood.",
|
||||||
|
"Lexical Resource": "Looks at the range and appropriateness of vocabulary. A higher score demonstrates a rich and precise vocabulary; a lower score suggests limited vocabulary usage and appropriateness.",
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}>
|
<Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}>
|
||||||
@@ -51,13 +58,13 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
|
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
|
||||||
padding: "32px 28px",
|
padding: "32px 28px",
|
||||||
},
|
},
|
||||||
marker: {display: "none"},
|
marker: { display: "none" },
|
||||||
diffRemoved: {padding: "32px 28px"},
|
diffRemoved: { padding: "32px 28px" },
|
||||||
diffAdded: {padding: "32px 28px"},
|
diffAdded: { padding: "32px 28px" },
|
||||||
|
|
||||||
wordRemoved: {padding: "0px", display: "initial"},
|
wordRemoved: { padding: "0px", display: "initial" },
|
||||||
wordAdded: {padding: "0px", display: "initial"},
|
wordAdded: { padding: "0px", display: "initial" },
|
||||||
wordDiff: {padding: "0px", display: "initial"},
|
wordDiff: { padding: "0px", display: "initial" },
|
||||||
}}
|
}}
|
||||||
oldValue={userSolutions[0].evaluation.transcript_1.replaceAll("\\n", "\n")}
|
oldValue={userSolutions[0].evaluation.transcript_1.replaceAll("\\n", "\n")}
|
||||||
newValue={userSolutions[0].evaluation.fixed_text_1.replaceAll("\\n", "\n")}
|
newValue={userSolutions[0].evaluation.fixed_text_1.replaceAll("\\n", "\n")}
|
||||||
@@ -126,23 +133,36 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<div className="flex gap-4 px-1">
|
<div className="flex gap-4 px-1">
|
||||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key, index) => {
|
||||||
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
||||||
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2")} key={key}>
|
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
|
||||||
|
index === 0 && "tooltip-right"
|
||||||
|
)} key={key} data-tip={tooltips[key] || "No additional information available"}>
|
||||||
{key}: Level {grade}
|
{key}: Level {grade}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{userSolutions[0].evaluation &&
|
{userSolutions[0].evaluation &&
|
||||||
(userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? (
|
(userSolutions[0].evaluation.perfect_answer || userSolutions[0].evaluation.perfect_answer_1) ? (
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
|
||||||
<Tab
|
<Tab
|
||||||
className={({selected}) =>
|
className={({ selected }) =>
|
||||||
|
clsx(
|
||||||
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||||
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
General Feedback
|
||||||
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
className={({ selected }) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
@@ -153,7 +173,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
Evaluation
|
Evaluation
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
className={({selected}) =>
|
className={({ selected }) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
||||||
@@ -163,30 +183,9 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
}>
|
}>
|
||||||
Recommended Answer
|
Recommended Answer
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
|
||||||
className={({selected}) =>
|
|
||||||
clsx(
|
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
|
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
Global Overview
|
|
||||||
</Tab>
|
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels>
|
<Tab.Panels>
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
{/* General Feedback */}
|
||||||
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
|
||||||
</Tab.Panel>
|
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
|
||||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
|
||||||
{userSolutions[0].evaluation!.perfect_answer &&
|
|
||||||
userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n")}
|
|
||||||
{userSolutions[0].evaluation!.perfect_answer_1 &&
|
|
||||||
userSolutions[0].evaluation!.perfect_answer_1.replaceAll(/\s{2,}/g, "\n\n")}
|
|
||||||
</span>
|
|
||||||
</Tab.Panel>
|
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
||||||
@@ -195,15 +194,28 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} className="flex flex-col gap-2">
|
<div key={key} className="flex flex-col gap-2">
|
||||||
<span className={"font-semibold"}>
|
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit")} key={key}>
|
||||||
{key}: Level {grade}
|
{key}: Level {grade}
|
||||||
</span>
|
</div>
|
||||||
{typeof taskResponse !== "number" && <span>{taskResponse.comment}</span>}
|
{typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
|
{/* Evaluation */}
|
||||||
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
||||||
|
</Tab.Panel>
|
||||||
|
{/* Recommended Answer */}
|
||||||
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||||
|
{userSolutions[0].evaluation!.perfect_answer &&
|
||||||
|
userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n")}
|
||||||
|
{userSolutions[0].evaluation!.perfect_answer_1 &&
|
||||||
|
userSolutions[0].evaluation!.perfect_answer_1.replaceAll(/\s{2,}/g, "\n\n")}
|
||||||
|
</span>
|
||||||
|
</Tab.Panel>
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
) : (
|
) : (
|
||||||
@@ -224,7 +236,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
|
|||||||
onBack({
|
onBack({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: userSolutions,
|
solutions: userSolutions,
|
||||||
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
score: { total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 },
|
||||||
type,
|
type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,15 +49,11 @@ function Blank({
|
|||||||
{userSolution && !isUserSolutionCorrect() && (
|
{userSolution && !isUserSolutionCorrect() && (
|
||||||
<div
|
<div
|
||||||
className="py-2 px-3 rounded-2xl w-fit focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light"
|
className="py-2 px-3 rounded-2xl w-fit focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light"
|
||||||
placeholder={id}
|
|
||||||
contentEditable={disabled}>
|
contentEditable={disabled}>
|
||||||
{userSolution}
|
{userSolution}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())} contentEditable={disabled}>
|
||||||
className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())}
|
|
||||||
placeholder={id}
|
|
||||||
contentEditable={disabled}>
|
|
||||||
{!solutions ? userInput : solutions.join(" / ")}
|
{!solutions ? userInput : solutions.join(" / ")}
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,23 +1,30 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {WritingExercise} from "@/interfaces/exam";
|
import { WritingExercise } from "@/interfaces/exam";
|
||||||
import {CommonProps} from ".";
|
import { CommonProps } from ".";
|
||||||
import {Fragment, useEffect, useState} from "react";
|
import { Fragment, useEffect, useState } from "react";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import {Dialog, Tab, Transition} from "@headlessui/react";
|
import { Dialog, Tab, Transition } from "@headlessui/react";
|
||||||
import {writingReverseMarking} from "@/utils/score";
|
import { writingReverseMarking } from "@/utils/score";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
import ReactDiffViewer, { DiffMethod } from "react-diff-viewer";
|
||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
import AIDetection from "../AIDetection";
|
import AIDetection from "../AIDetection";
|
||||||
|
|
||||||
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
|
export default function Writing({ id, type, prompt, attachment, userSolutions, onNext, onBack }: WritingExercise & CommonProps) {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [showDiff, setShowDiff] = useState(false);
|
const [showDiff, setShowDiff] = useState(false);
|
||||||
|
|
||||||
const {user} = useUser();
|
const { user } = useUser();
|
||||||
|
|
||||||
const aiEval = userSolutions && userSolutions.length > 0 ? userSolutions[0].evaluation?.ai_detection : undefined;
|
const aiEval = userSolutions && userSolutions.length > 0 ? userSolutions[0].evaluation?.ai_detection : undefined;
|
||||||
|
|
||||||
|
const tooltips: { [key: string]: string } = {
|
||||||
|
"Lexical Resource": "Assesses the diversity and accuracy of vocabulary used. A higher score indicates varied and precise word choice; a lower score points to limited vocabulary and inaccuracies.",
|
||||||
|
"Task Achievement": "Evaluates how well the task requirements are fulfilled. A higher score means all parts of the task are addressed thoroughly; a lower score shows incomplete or inadequate task response.",
|
||||||
|
"Coherence and Cohesion": "Measures logical organization and flow of writing. A higher score reflects well-structured and connected ideas; a lower score indicates disorganized writing and poor linkage between ideas.",
|
||||||
|
"Grammatical Range and Accuracy": "Looks at the range and precision of grammatical structures. A higher score shows varied and accurate grammar use; a lower score suggests frequent errors and limited range.",
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{attachment && (
|
{attachment && (
|
||||||
@@ -92,13 +99,13 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
|
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
|
||||||
padding: "32px 28px",
|
padding: "32px 28px",
|
||||||
},
|
},
|
||||||
marker: {display: "none"},
|
marker: { display: "none" },
|
||||||
diffRemoved: {padding: "32px 28px"},
|
diffRemoved: { padding: "32px 28px" },
|
||||||
diffAdded: {padding: "32px 28px"},
|
diffAdded: { padding: "32px 28px" },
|
||||||
|
|
||||||
wordRemoved: {padding: "0px", display: "initial"},
|
wordRemoved: { padding: "0px", display: "initial" },
|
||||||
wordAdded: {padding: "0px", display: "initial"},
|
wordAdded: { padding: "0px", display: "initial" },
|
||||||
wordDiff: {padding: "0px", display: "initial"},
|
wordDiff: { padding: "0px", display: "initial" },
|
||||||
}}
|
}}
|
||||||
oldValue={userSolutions[0].solution.replaceAll("\\n", "\n")}
|
oldValue={userSolutions[0].solution.replaceAll("\\n", "\n")}
|
||||||
newValue={userSolutions[0].evaluation!.fixed_text!.replaceAll("\\n", "\n")}
|
newValue={userSolutions[0].evaluation!.fixed_text!.replaceAll("\\n", "\n")}
|
||||||
@@ -123,12 +130,15 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<div className="flex gap-4 px-1">
|
<div className="flex gap-4 px-1">
|
||||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key, index) => {
|
||||||
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
const taskResponse = userSolutions[0].evaluation!.task_response[key];
|
||||||
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2")} key={key}>
|
<div className={clsx(
|
||||||
|
"bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
|
||||||
|
index === 0 && "tooltip-right"
|
||||||
|
)} key={key} data-tip={tooltips[key] || "No additional information available"}>
|
||||||
{key}: Level {grade}
|
{key}: Level {grade}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -138,7 +148,18 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
|
||||||
<Tab
|
<Tab
|
||||||
className={({selected}) =>
|
className={({ selected }) =>
|
||||||
|
clsx(
|
||||||
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
||||||
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
General Feedback
|
||||||
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
className={({ selected }) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||||
@@ -149,7 +170,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
Evaluation
|
Evaluation
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
className={({selected}) =>
|
className={({ selected }) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||||
@@ -159,20 +180,9 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
}>
|
}>
|
||||||
Recommended Answer
|
Recommended Answer
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
|
||||||
className={({selected}) =>
|
|
||||||
clsx(
|
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
Global Overview
|
|
||||||
</Tab>
|
|
||||||
{aiEval && user?.type !== "student" && (
|
{aiEval && user?.type !== "student" && (
|
||||||
<Tab
|
<Tab
|
||||||
className={({selected}) =>
|
className={({ selected }) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
|
||||||
@@ -185,14 +195,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
)}
|
)}
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels>
|
<Tab.Panels>
|
||||||
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
{/* Global */}
|
||||||
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
|
||||||
</Tab.Panel>
|
|
||||||
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
|
||||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
|
||||||
{userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n").replaceAll("\\n", "\n")}
|
|
||||||
</span>
|
|
||||||
</Tab.Panel>
|
|
||||||
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
|
||||||
@@ -201,15 +204,25 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} className="flex flex-col gap-2">
|
<div key={key} className="flex flex-col gap-2">
|
||||||
<span className={"font-semibold"}>
|
<div className={clsx("bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 w-fit")} key={key}>
|
||||||
{key}: Level {grade}
|
{key}: Level {grade}
|
||||||
</span>
|
</div>
|
||||||
{typeof taskResponse !== "number" && <span>{taskResponse.comment}</span>}
|
{typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
|
{/* Evaluation */}
|
||||||
|
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
||||||
|
</Tab.Panel>
|
||||||
|
{/* Recommended Answer */}
|
||||||
|
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
|
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||||
|
{userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n").replaceAll("\\n", "\n")}
|
||||||
|
</span>
|
||||||
|
</Tab.Panel>
|
||||||
{aiEval && user?.type !== "student" && (
|
{aiEval && user?.type !== "student" && (
|
||||||
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
<AIDetection {...aiEval} />
|
<AIDetection {...aiEval} />
|
||||||
@@ -235,7 +248,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
onBack({
|
onBack({
|
||||||
exercise: id,
|
exercise: id,
|
||||||
solutions: userSolutions,
|
solutions: userSolutions,
|
||||||
score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
score: { total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 },
|
||||||
type,
|
type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
289
src/components/StatGridItem.tsx
Normal file
289
src/components/StatGridItem.tsx
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BsClock, BsXCircle } from 'react-icons/bs';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { Stat, User } from '@/interfaces/user';
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import ai_usage from "@/utils/ai.detection";
|
||||||
|
import { calculateBandScore } from "@/utils/score";
|
||||||
|
import moment from 'moment';
|
||||||
|
import { Assignment } from '@/interfaces/results';
|
||||||
|
import { uuidv4 } from "@firebase/util";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { uniqBy } from "lodash";
|
||||||
|
import { sortByModule } from "@/utils/moduleUtils";
|
||||||
|
import { convertToUserSolutions } from "@/utils/stats";
|
||||||
|
import { getExamById } from "@/utils/exams";
|
||||||
|
import { Exam, UserSolution } from '@/interfaces/exam';
|
||||||
|
import ModuleBadge from './ModuleBadge';
|
||||||
|
|
||||||
|
const formatTimestamp = (timestamp: string | number) => {
|
||||||
|
const time = typeof timestamp === "string" ? parseInt(timestamp) : timestamp;
|
||||||
|
const date = moment(time);
|
||||||
|
const formatter = "YYYY/MM/DD - HH:mm";
|
||||||
|
return date.format(formatter);
|
||||||
|
};
|
||||||
|
|
||||||
|
const aggregateScoresByModule = (stats: Stat[]): { module: Module; total: number; missing: number; correct: number }[] => {
|
||||||
|
const scores: {
|
||||||
|
[key in Module]: { total: number; missing: number; correct: number };
|
||||||
|
} = {
|
||||||
|
reading: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
listening: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
writing: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
speaking: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
level: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
stats.forEach((x) => {
|
||||||
|
scores[x.module!] = {
|
||||||
|
total: scores[x.module!].total + x.score.total,
|
||||||
|
correct: scores[x.module!].correct + x.score.correct,
|
||||||
|
missing: scores[x.module!].missing + x.score.missing,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.keys(scores)
|
||||||
|
.filter((x) => scores[x as Module].total > 0)
|
||||||
|
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StatsGridItemProps {
|
||||||
|
width?: string | undefined;
|
||||||
|
height?: string | undefined;
|
||||||
|
examNumber?: number | undefined;
|
||||||
|
stats: Stat[];
|
||||||
|
timestamp: string | number;
|
||||||
|
user: User,
|
||||||
|
assignments: Assignment[];
|
||||||
|
users: User[];
|
||||||
|
training?: boolean,
|
||||||
|
selectedTrainingExams?: string[];
|
||||||
|
maxTrainingExams?: number;
|
||||||
|
setSelectedTrainingExams?: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
|
setExams: (exams: Exam[]) => void;
|
||||||
|
setShowSolutions: (show: boolean) => void;
|
||||||
|
setUserSolutions: (solutions: UserSolution[]) => void;
|
||||||
|
setSelectedModules: (modules: Module[]) => void;
|
||||||
|
setInactivity: (inactivity: number) => void;
|
||||||
|
setTimeSpent: (time: number) => void;
|
||||||
|
renderPdfIcon: (session: string, color: string, textColor: string) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatsGridItem: React.FC<StatsGridItemProps> = ({
|
||||||
|
stats,
|
||||||
|
timestamp,
|
||||||
|
user,
|
||||||
|
assignments,
|
||||||
|
users,
|
||||||
|
training,
|
||||||
|
selectedTrainingExams,
|
||||||
|
setSelectedTrainingExams,
|
||||||
|
setExams,
|
||||||
|
setShowSolutions,
|
||||||
|
setUserSolutions,
|
||||||
|
setSelectedModules,
|
||||||
|
setInactivity,
|
||||||
|
setTimeSpent,
|
||||||
|
renderPdfIcon,
|
||||||
|
width = undefined,
|
||||||
|
height = undefined,
|
||||||
|
examNumber = undefined,
|
||||||
|
maxTrainingExams = undefined
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
||||||
|
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
||||||
|
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
||||||
|
const assignmentID = stats.reduce((_, current) => current.assignment as any, "");
|
||||||
|
const assignment = assignments.find((a) => a.id === assignmentID);
|
||||||
|
const isDisabled = stats.some((x) => x.isDisabled);
|
||||||
|
|
||||||
|
const aiUsage = Math.round(ai_usage(stats) * 100);
|
||||||
|
|
||||||
|
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||||
|
module: x.module,
|
||||||
|
level: calculateBandScore(x.correct, x.total, x.module, user.focus),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const textColor = clsx(
|
||||||
|
correct / total >= 0.7 && "text-mti-purple",
|
||||||
|
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||||
|
correct / total < 0.3 && "text-mti-rose",
|
||||||
|
);
|
||||||
|
|
||||||
|
const { timeSpent, inactivity, session } = stats[0];
|
||||||
|
|
||||||
|
const selectExam = () => {
|
||||||
|
if (training && !isDisabled && typeof maxTrainingExams !== "undefined" && typeof setSelectedTrainingExams !== "undefined" && typeof timestamp == "string") {
|
||||||
|
setSelectedTrainingExams(prevExams => {
|
||||||
|
const uniqueExams = [...new Set(stats.map(stat => `${stat.module}-${stat.date}`))];
|
||||||
|
const indexes = uniqueExams.map(exam => prevExams.indexOf(exam)).filter(index => index !== -1);
|
||||||
|
if (indexes.length > 0) {
|
||||||
|
const newExams = [...prevExams];
|
||||||
|
indexes.sort((a, b) => b - a).forEach(index => {
|
||||||
|
newExams.splice(index, 1);
|
||||||
|
});
|
||||||
|
return newExams;
|
||||||
|
} else {
|
||||||
|
if (prevExams.length + uniqueExams.length <= maxTrainingExams) {
|
||||||
|
return [...prevExams, ...uniqueExams];
|
||||||
|
} else {
|
||||||
|
return prevExams;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const examPromises = uniqBy(stats, "exam").map((stat) => {
|
||||||
|
return getExamById(stat.module, stat.exam);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDisabled) return;
|
||||||
|
|
||||||
|
Promise.all(examPromises).then((exams) => {
|
||||||
|
if (exams.every((x) => !!x)) {
|
||||||
|
if (!!timeSpent) setTimeSpent(timeSpent);
|
||||||
|
if (!!inactivity) setInactivity(inactivity);
|
||||||
|
setUserSolutions(convertToUserSolutions(stats));
|
||||||
|
setShowSolutions(true);
|
||||||
|
setExams(exams.map((x) => x!).sort(sortByModule));
|
||||||
|
setSelectedModules(
|
||||||
|
exams
|
||||||
|
.map((x) => x!)
|
||||||
|
.sort(sortByModule)
|
||||||
|
.map((x) => x!.module),
|
||||||
|
);
|
||||||
|
router.push("/exercises");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
|
||||||
|
<div className="flex flex-col md:gap-1 -md:gap-2 2xl:gap-2">
|
||||||
|
<span className="font-medium">{formatTimestamp(timestamp)}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!!timeSpent && (
|
||||||
|
<span className="text-sm flex gap-2 items-center tooltip" data-tip="Time Spent">
|
||||||
|
<BsClock /> {Math.floor(timeSpent / 60)} minutes
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!!inactivity && (
|
||||||
|
<span className="text-sm flex gap-2 items-center tooltip" data-tip="Inactivity">
|
||||||
|
<BsXCircle /> {Math.floor(inactivity / 60)} minutes
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
<span className={textColor}>
|
||||||
|
Level{" "}
|
||||||
|
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
{renderPdfIcon(session, textColor, textColor)}
|
||||||
|
</div>
|
||||||
|
{examNumber === undefined ? (
|
||||||
|
<>
|
||||||
|
{aiUsage >= 50 && user.type !== "student" && (
|
||||||
|
<div className={clsx(
|
||||||
|
"ml-auto border px-1 rounded w-fit mr-1",
|
||||||
|
{
|
||||||
|
'bg-orange-100 border-orange-400 text-orange-700': aiUsage < 80,
|
||||||
|
'bg-red-100 border-red-400 text-red-700': aiUsage >= 80,
|
||||||
|
}
|
||||||
|
)}>
|
||||||
|
<span className="text-xs">AI Usage</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className='flex justify-end'>
|
||||||
|
<span className="font-semibold bg-gray-200 text-gray-800 px-2.5 py-0.5 rounded-full mt-0.5">{examNumber}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full flex flex-col gap-1">
|
||||||
|
<div className={clsx(
|
||||||
|
"grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2",
|
||||||
|
examNumber !== undefined && "pr-10"
|
||||||
|
)}>
|
||||||
|
{aggregatedLevels.map(({ module, level }) => (
|
||||||
|
<ModuleBadge key={module} module={module} level={level} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{assignment && (
|
||||||
|
<span className="font-light text-sm">
|
||||||
|
Assignment: {assignment.name}, Teacher: {users.find((u) => u.id === assignment.assigner)?.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
key={uuidv4()}
|
||||||
|
className={clsx(
|
||||||
|
"flex flex-col justify-between gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden",
|
||||||
|
isDisabled && "grayscale tooltip",
|
||||||
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
|
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||||
|
correct / total < 0.3 && "hover:border-mti-rose",
|
||||||
|
typeof selectedTrainingExams !== "undefined" && typeof timestamp === "string" && selectedTrainingExams.some(exam => exam.includes(timestamp)) && "border-2 border-slate-600",
|
||||||
|
)}
|
||||||
|
onClick={examNumber === undefined ? selectExam : undefined}
|
||||||
|
style={{
|
||||||
|
...(width !== undefined && { width }),
|
||||||
|
...(height !== undefined && { height }),
|
||||||
|
}}
|
||||||
|
data-tip="This exam is still being evaluated..."
|
||||||
|
role="button">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
key={uuidv4()}
|
||||||
|
className={clsx(
|
||||||
|
"flex flex-col gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:tooltip md:hidden",
|
||||||
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
|
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||||
|
correct / total < 0.3 && "hover:border-mti-rose",
|
||||||
|
)}
|
||||||
|
data-tip="Your screen size is too small to view previous exams."
|
||||||
|
style={{
|
||||||
|
...(width !== undefined && { width }),
|
||||||
|
...(height !== undefined && { height }),
|
||||||
|
}}
|
||||||
|
role="button">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatsGridItem;
|
||||||
91
src/components/TrainingContent/Exercise.tsx
Normal file
91
src/components/TrainingContent/Exercise.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import React, { useState, useCallback } from "react";
|
||||||
|
import ExerciseWalkthrough from "@/training/ExerciseWalkthrough";
|
||||||
|
import { ITrainingTip, WalkthroughConfigs } from "./TrainingInterfaces";
|
||||||
|
|
||||||
|
|
||||||
|
// This wrapper is just to test new exercises from the handbook, will be removed when all the tips and exercises are in firestore
|
||||||
|
const TrainingExercise: React.FC<ITrainingTip> = (trainingTip: ITrainingTip) => {
|
||||||
|
const leftText = "<div class=\"container mx-auto px-4 overflow-x-auto\"><table class=\"min-w-full bg-white border border-gray-300\"><thead><tr class=\"bg-gray-100\"><th class=\"py-2 px-4 border-b font-semibold text-left\">Category</th><th class=\"py-2 px-4 border-b font-semibold text-left\">Option A</th><th class=\"py-2 px-4 border-b font-semibold text-left\">Option B</th></tr></thead><tbody><tr><td class=\"py-2 px-4 border-b font-medium\">Self</td><td class=\"py-2 px-4 border-b\">You need to take care of yourself and connect with the people around you.</td><td class=\"py-2 px-4 border-b\">Focus on your interests and talents and meet people who are like you.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Home</td><td class=\"py-2 px-4 border-b\">It's a good idea to paint your living room yellow.</td><td class=\"py-2 px-4 border-b\">You should arrange your home so that it makes you feel happy.</td></tr><tr><td class=\"py-2 px-4 border-b font-medium\">Financial Life</td><td class=\"py-2 px-4 border-b\">You can be happy if you have enough money, but don't want money too much.</td><td class=\"py-2 px-4 border-b\">If you waste money on things you don't need, you won't have enough money for things that you do need.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Social Life</td><td class=\"py-2 px-4 border-b\">A good group of friends can increase your happiness.</td><td class=\"py-2 px-4 border-b\">Researchers say that a happy friend can increase our mood by nine percent.</td></tr><tr><td class=\"py-2 px-4 border-b font-medium\">Workplace</td><td class=\"py-2 px-4 border-b\">You spend a lot of time at work, so you should like your workplace.</td><td class=\"py-2 px-4 border-b\">Your boss needs to be someone you enjoy working for.</td></tr><tr class=\"bg-gray-50\"><td class=\"py-2 px-4 border-b font-medium\">Community</td><td class=\"py-2 px-4 border-b\">The place where you live is more important for happiness than anything else.</td><td class=\"py-2 px-4 border-b\">Live around people who have the same amount of money as you do.</td></tr></tbody></table></div>";
|
||||||
|
const tip = {
|
||||||
|
category: "Strategy",
|
||||||
|
body: "<p>Look for <b>clues to the main idea</b> in the first (and sometimes second) sentence of a paragraph.</p>"
|
||||||
|
}
|
||||||
|
const question = "<div class=\"container mx-auto px-4 py-8\"><h2 class=\"text-2xl font-bold mb-4\">Identifying Main Ideas</h2><p class=\"text-lg leading-relaxed mb-6\">Read the statements below. Circle the main idea in each pair of statements (a or b).</p></div>";
|
||||||
|
const rightTextData: WalkthroughConfigs[] = [
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-blue-100 p-4 rounded-lg mb-4'><h2 class='text-xl font-bold mb-2'>Identifying Main Ideas</h2><p class='text-gray-700 leading-relaxed'>Let's analyze each pair of statements to determine which one represents the main idea. We'll focus on which statement is more general and encompasses the overall concept.</p></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 5000,
|
||||||
|
"highlight": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-green-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>1. Self</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You need to take care of yourself and connect with the people around you.</b></p><p class='mt-2'>This statement is more comprehensive, covering both self-care and social connections. Option B is more specific and could be considered a subset of A.</p></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 8000,
|
||||||
|
"highlight": ["You need to take care of yourself and connect with the people around you."]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-yellow-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>2. Home</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>B. You should arrange your home so that it makes you feel happy.</b></p><p class='mt-2'>This statement is more general and applies to the entire home. Option A is a specific example that could fall under this broader concept.</p></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 8000,
|
||||||
|
"highlight": ["You should arrange your home so that it makes you feel happy."]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-pink-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>3. Financial Life</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You can be happy if you have enough money, but don't want money too much.</b></p><p class='mt-2'>This statement provides a balanced view of money's role in happiness. Option B is more specific and could be seen as a consequence of wanting money too much.</p></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 8000,
|
||||||
|
"highlight": ["You can be happy if you have enough money, but don't want money too much."]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-purple-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>4. Social Life</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. A good group of friends can increase your happiness.</b></p><p class='mt-2'>This statement is more general about the impact of friendships. Option B provides a specific statistic that supports this main idea.</p></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 8000,
|
||||||
|
"highlight": ["A good group of friends can increase your happiness."]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-indigo-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>5. Workplace</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. You spend a lot of time at work, so you should like your workplace.</b></p><p class='mt-2'>This statement covers the overall importance of workplace satisfaction. Option B focuses on one specific aspect (the boss) and is less comprehensive.</p></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 8000,
|
||||||
|
"highlight": ["You spend a lot of time at work, so you should like your workplace."]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-red-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>6. Community</h3><p class='text-gray-700 leading-relaxed'>Main idea: <b>A. The place where you live is more important for happiness than anything else.</b></p><p class='mt-2'>While this statement might be debatable, it's more general and encompasses the overall importance of community. Option B is a specific suggestion about community demographics.</p></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 8000,
|
||||||
|
"highlight": ["The place where you live is more important for happiness than anything else."]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-orange-50 p-4 rounded-lg mb-4'><h3 class='text-lg font-semibold mb-2'>Key Strategy</h3><p class='text-gray-700 leading-relaxed'>When identifying main ideas:</p><ul class='list-disc pl-5 space-y-2'><li>Look for broader, more encompassing statements</li><li>Consider which statement other ideas could fall under</li><li>Identify which statement provides a general principle rather than a specific example</li></ul></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 8000,
|
||||||
|
"highlight": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"html": "<div class='bg-teal-50 p-4 rounded-lg'><h3 class='text-lg font-semibold mb-2'>Helpful Tip</h3><p class='text-gray-700 leading-relaxed'>Remember to look for clues to the main idea in the first (and sometimes second) sentence of a paragraph. In this exercise, we applied this concept to pairs of statements. This approach can help you quickly identify the central theme or main point in various types of text.</p></div>",
|
||||||
|
"wordDelay": 200,
|
||||||
|
"holdDelay": 5000,
|
||||||
|
"highlight": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockTip: ITrainingTip = {
|
||||||
|
id: "some random id",
|
||||||
|
tipCategory: tip.category,
|
||||||
|
tipHtml: tip.body,
|
||||||
|
standalone: false,
|
||||||
|
exercise: {
|
||||||
|
question: question,
|
||||||
|
highlightable: leftText,
|
||||||
|
segments: rightTextData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col p-10">
|
||||||
|
<ExerciseWalkthrough {...trainingTip}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrainingExercise;
|
||||||
280
src/components/TrainingContent/ExerciseWalkthrough.tsx
Normal file
280
src/components/TrainingContent/ExerciseWalkthrough.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import React, {useState, useEffect, useRef, useCallback} from "react";
|
||||||
|
import {animated} from "@react-spring/web";
|
||||||
|
import {FaRegCirclePlay, FaRegCircleStop} from "react-icons/fa6";
|
||||||
|
import HighlightContent from "../HighlightContent";
|
||||||
|
import {ITrainingTip, SegmentRef, TimelineEvent} from "./TrainingInterfaces";
|
||||||
|
|
||||||
|
const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
||||||
|
const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState<number>(0);
|
||||||
|
const [walkthroughHtml, setWalkthroughHtml] = useState<string>("");
|
||||||
|
const [highlightedPhrases, setHighlightedPhrases] = useState<string[]>([]);
|
||||||
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
|
const timelineRef = useRef<TimelineEvent[]>([]);
|
||||||
|
const animationRef = useRef<number | null>(null);
|
||||||
|
const segmentsRef = useRef<SegmentRef[]>([]);
|
||||||
|
|
||||||
|
const toggleAutoPlay = useCallback(() => {
|
||||||
|
setIsAutoPlaying((prev) => {
|
||||||
|
if (!prev && currentTime === getMaxTime()) {
|
||||||
|
setCurrentTime(0);
|
||||||
|
}
|
||||||
|
return !prev;
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentTime]);
|
||||||
|
|
||||||
|
const handleAnimationComplete = useCallback(() => {
|
||||||
|
setIsAutoPlaying(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleResetAnimation = useCallback((newTime: number) => {
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getMaxTime = (): number => {
|
||||||
|
return (
|
||||||
|
tip.exercise?.segments.reduce((sum, segment) => sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0) ?? 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeline: TimelineEvent[] = [];
|
||||||
|
let currentTimePosition = 0;
|
||||||
|
segmentsRef.current = [];
|
||||||
|
|
||||||
|
tip.exercise?.segments.forEach((segment, index) => {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(segment.html, "text/html");
|
||||||
|
const words: string[] = [];
|
||||||
|
const walkTree = (node: Node) => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
words.push(...(node.textContent?.split(/\s+/).filter((word) => word.length > 0) || []));
|
||||||
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
Array.from(node.childNodes).forEach(walkTree);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walkTree(doc.body);
|
||||||
|
|
||||||
|
const textDuration = words.length * segment.wordDelay;
|
||||||
|
|
||||||
|
segmentsRef.current.push({
|
||||||
|
...segment,
|
||||||
|
words: words,
|
||||||
|
startTime: currentTimePosition,
|
||||||
|
endTime: currentTimePosition + textDuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
timeline.push({
|
||||||
|
type: "text",
|
||||||
|
start: currentTimePosition,
|
||||||
|
end: currentTimePosition + textDuration,
|
||||||
|
segmentIndex: index,
|
||||||
|
});
|
||||||
|
|
||||||
|
currentTimePosition += textDuration;
|
||||||
|
|
||||||
|
timeline.push({
|
||||||
|
type: "highlight",
|
||||||
|
start: currentTimePosition,
|
||||||
|
end: currentTimePosition + segment.holdDelay,
|
||||||
|
content: segment.highlight,
|
||||||
|
segmentIndex: index,
|
||||||
|
});
|
||||||
|
|
||||||
|
currentTimePosition += segment.holdDelay;
|
||||||
|
});
|
||||||
|
|
||||||
|
timelineRef.current = timeline;
|
||||||
|
}, [tip.exercise?.segments]);
|
||||||
|
|
||||||
|
const updateText = useCallback(() => {
|
||||||
|
const currentEvent = timelineRef.current.find((event) => currentTime >= event.start && currentTime < event.end);
|
||||||
|
|
||||||
|
if (currentEvent) {
|
||||||
|
if (currentEvent.type === "text") {
|
||||||
|
const segment = segmentsRef.current[currentEvent.segmentIndex];
|
||||||
|
const elapsedTime = currentTime - currentEvent.start;
|
||||||
|
const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length);
|
||||||
|
|
||||||
|
const previousSegmentsHtml = segmentsRef.current
|
||||||
|
.slice(0, currentEvent.segmentIndex)
|
||||||
|
.map((seg) => seg.html)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(segment.html, "text/html");
|
||||||
|
let wordCount = 0;
|
||||||
|
const walkTree = (node: Node, action: (node: Node) => void): boolean => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE && node.textContent) {
|
||||||
|
const words = node.textContent.split(/(\s+)/).filter((word) => word.length > 0);
|
||||||
|
if (wordCount + words.filter((w) => !/\s+/.test(w)).length <= wordsToShow) {
|
||||||
|
action(node.cloneNode(true));
|
||||||
|
wordCount += words.filter((w) => !/\s+/.test(w)).length;
|
||||||
|
} else {
|
||||||
|
const remainingWords = wordsToShow - wordCount;
|
||||||
|
const newTextContent = words.reduce(
|
||||||
|
(acc, word) => {
|
||||||
|
if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) {
|
||||||
|
acc.text += word;
|
||||||
|
acc.nonSpaceWords++;
|
||||||
|
} else if (/\s+/.test(word) || acc.nonSpaceWords < remainingWords) {
|
||||||
|
acc.text += word;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{text: "", nonSpaceWords: 0},
|
||||||
|
).text;
|
||||||
|
const newNode = node.cloneNode(false);
|
||||||
|
newNode.textContent = newTextContent;
|
||||||
|
action(newNode);
|
||||||
|
wordCount = wordsToShow;
|
||||||
|
}
|
||||||
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const clone = node.cloneNode(false);
|
||||||
|
action(clone);
|
||||||
|
Array.from(node.childNodes).some((child) => {
|
||||||
|
return walkTree(child, (childNode) => (clone as Node).appendChild(childNode));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return wordCount >= wordsToShow;
|
||||||
|
};
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
walkTree(doc.body, (node) => fragment.appendChild(node));
|
||||||
|
|
||||||
|
const serializer = new XMLSerializer();
|
||||||
|
const currentSegmentHtml = Array.from(fragment.childNodes)
|
||||||
|
.map((node) => serializer.serializeToString(node))
|
||||||
|
.join("");
|
||||||
|
const newHtml = previousSegmentsHtml + currentSegmentHtml;
|
||||||
|
|
||||||
|
setWalkthroughHtml(newHtml);
|
||||||
|
setHighlightedPhrases([]);
|
||||||
|
} else if (currentEvent.type === "highlight") {
|
||||||
|
const newHtml = segmentsRef.current
|
||||||
|
.slice(0, currentEvent.segmentIndex + 1)
|
||||||
|
.map((seg) => seg.html)
|
||||||
|
.join("");
|
||||||
|
setWalkthroughHtml(newHtml);
|
||||||
|
setHighlightedPhrases(currentEvent.content || []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [currentTime]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateText();
|
||||||
|
}, [currentTime, updateText]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAutoPlaying) {
|
||||||
|
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
|
||||||
|
if (lastEvent && currentTime >= lastEvent.end) {
|
||||||
|
setCurrentTime(0);
|
||||||
|
}
|
||||||
|
setIsPlaying(true);
|
||||||
|
} else {
|
||||||
|
setIsPlaying(false);
|
||||||
|
}
|
||||||
|
}, [isAutoPlaying, currentTime]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const animate = () => {
|
||||||
|
if (isPlaying) {
|
||||||
|
setCurrentTime((prevTime) => {
|
||||||
|
const newTime = prevTime + 50;
|
||||||
|
const lastEvent = timelineRef.current[timelineRef.current.length - 1];
|
||||||
|
if (lastEvent && newTime >= lastEvent.end) {
|
||||||
|
setIsPlaying(false);
|
||||||
|
handleAnimationComplete();
|
||||||
|
return lastEvent.end;
|
||||||
|
}
|
||||||
|
return newTime;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationRef.current) {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isPlaying, handleAnimationComplete]);
|
||||||
|
|
||||||
|
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newTime = parseInt(e.target.value, 10);
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
handleResetAnimation(newTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSliderMouseDown = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSliderMouseUp = () => {
|
||||||
|
if (isAutoPlaying) {
|
||||||
|
setIsPlaying(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tip.standalone || !tip.exercise) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<h1 className="text-xl font-bold text-red-600">The exercise for this tip is not available yet!</h1>
|
||||||
|
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4 mt-10">
|
||||||
|
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
|
||||||
|
<div className="text-gray-700" dangerouslySetInnerHTML={{__html: tip.tipHtml}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4">
|
||||||
|
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
|
||||||
|
<div className="text-gray-700" dangerouslySetInnerHTML={{__html: tip.tipHtml}} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
<div className="flex flex-row items-center space-x-4 py-4">
|
||||||
|
<button
|
||||||
|
onClick={toggleAutoPlay}
|
||||||
|
className="p-2 bg-blue-500 text-white rounded-full transition-colors duration-200 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
|
||||||
|
aria-label={isAutoPlaying ? "Pause" : "Play"}>
|
||||||
|
{isAutoPlaying ? <FaRegCircleStop className="w-6 h-6" /> : <FaRegCirclePlay className="w-6 h-6" />}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={timelineRef.current.length > 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0}
|
||||||
|
value={currentTime}
|
||||||
|
onChange={handleSliderChange}
|
||||||
|
onMouseDown={handleSliderMouseDown}
|
||||||
|
onMouseUp={handleSliderMouseUp}
|
||||||
|
onTouchStart={handleSliderMouseDown}
|
||||||
|
onTouchEnd={handleSliderMouseUp}
|
||||||
|
className="flex-grow"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4">
|
||||||
|
<div className="flex-1 bg-white p-6 rounded-lg shadow">
|
||||||
|
{/*<h2 className="text-xl font-bold mb-4">Question</h2>*/}
|
||||||
|
<div className="mb-4" dangerouslySetInnerHTML={{__html: tip.exercise.question}} />
|
||||||
|
<HighlightContent html={tip.exercise.highlightable} highlightPhrases={highlightedPhrases} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="bg-gray-50 rounded-lg shadow">
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<animated.div dangerouslySetInnerHTML={{__html: walkthroughHtml}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExerciseWalkthrough;
|
||||||
56
src/components/TrainingContent/TrainingInterfaces.ts
Normal file
56
src/components/TrainingContent/TrainingInterfaces.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Stat } from "@/interfaces/user";
|
||||||
|
|
||||||
|
export interface ITrainingContent {
|
||||||
|
id: string;
|
||||||
|
created_at: number;
|
||||||
|
exams: {
|
||||||
|
id: string;
|
||||||
|
date: number;
|
||||||
|
detailed_summary: string;
|
||||||
|
performance_comment: string;
|
||||||
|
score: number;
|
||||||
|
module: string;
|
||||||
|
stat_ids: string[];
|
||||||
|
stats?: Stat[];
|
||||||
|
}[];
|
||||||
|
tip_ids: string[];
|
||||||
|
tips?: ITrainingTip[];
|
||||||
|
weak_areas: {
|
||||||
|
area: string;
|
||||||
|
comment: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITrainingTip {
|
||||||
|
id: string;
|
||||||
|
tipCategory: string;
|
||||||
|
tipHtml: string;
|
||||||
|
standalone: boolean;
|
||||||
|
exercise?: {
|
||||||
|
question: string;
|
||||||
|
highlightable: string;
|
||||||
|
segments: WalkthroughConfigs[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalkthroughConfigs {
|
||||||
|
html: string;
|
||||||
|
wordDelay: number;
|
||||||
|
holdDelay: number;
|
||||||
|
highlight: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface TimelineEvent {
|
||||||
|
type: 'text' | 'highlight';
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
segmentIndex: number;
|
||||||
|
content?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SegmentRef extends WalkthroughConfigs {
|
||||||
|
words: string[];
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
}
|
||||||
91
src/components/TrainingContent/TrainingScore.tsx
Normal file
91
src/components/TrainingContent/TrainingScore.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RiArrowRightUpLine, RiArrowLeftDownLine } from 'react-icons/ri';
|
||||||
|
import { FaChartLine } from 'react-icons/fa';
|
||||||
|
import { GiLightBulb } from 'react-icons/gi';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { ITrainingContent } from './TrainingInterfaces';
|
||||||
|
|
||||||
|
interface TrainingScoreProps {
|
||||||
|
trainingContent: ITrainingContent
|
||||||
|
gridView: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TrainingScore: React.FC<TrainingScoreProps> = ({
|
||||||
|
trainingContent,
|
||||||
|
gridView
|
||||||
|
}) => {
|
||||||
|
const scores = trainingContent.exams.map(exam => exam.score);
|
||||||
|
const highestScore = Math.max(...scores);
|
||||||
|
const lowestScore = Math.min(...scores);
|
||||||
|
let averageScore = scores.length > 0
|
||||||
|
? scores.reduce((sum, score) => sum + score, 0) / scores.length
|
||||||
|
: 0;
|
||||||
|
averageScore = Math.round(averageScore);
|
||||||
|
|
||||||
|
const containerClasses = clsx(
|
||||||
|
"flex flex-row mb-4",
|
||||||
|
gridView ? "gap-4 justify-between" : "gap-8"
|
||||||
|
);
|
||||||
|
|
||||||
|
const columnClasses = clsx(
|
||||||
|
"flex flex-col",
|
||||||
|
gridView ? "gap-4" : "gap-8"
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClasses}>
|
||||||
|
<div className={columnClasses}>
|
||||||
|
<div className="flex flex-row items-center gap-4">
|
||||||
|
<div className="flex w-14 h-14 bg-[#F5F5F5] items-center justify-center rounded-xl border border-[#DBDBDB]">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M11.7083 3.16669C11.4166 3.16669 11.1701 3.06599 10.9687 2.8646C10.7673 2.66321 10.6666 2.41669 10.6666 2.12502C10.6666 1.83335 10.7673 1.58683 10.9687 1.38544C11.1701 1.18405 11.4166 1.08335 11.7083 1.08335C12 1.08335 12.2465 1.18405 12.4479 1.38544C12.6493 1.58683 12.75 1.83335 12.75 2.12502C12.75 2.41669 12.6493 2.66321 12.4479 2.8646C12.2465 3.06599 12 3.16669 11.7083 3.16669ZM11.7083 16.9167C11.4166 16.9167 11.1701 16.816 10.9687 16.6146C10.7673 16.4132 10.6666 16.1667 10.6666 15.875C10.6666 15.5834 10.7673 15.3368 10.9687 15.1354C11.1701 14.934 11.4166 14.8334 11.7083 14.8334C12 14.8334 12.2465 14.934 12.4479 15.1354C12.6493 15.3368 12.75 15.5834 12.75 15.875C12.75 16.1667 12.6493 16.4132 12.4479 16.6146C12.2465 16.816 12 16.9167 11.7083 16.9167ZM15.0416 6.08335C14.75 6.08335 14.5034 5.98266 14.302 5.78127C14.1007 5.57988 14 5.33335 14 5.04169C14 4.75002 14.1007 4.50349 14.302 4.3021C14.5034 4.10071 14.75 4.00002 15.0416 4.00002C15.3333 4.00002 15.5798 4.10071 15.7812 4.3021C15.9826 4.50349 16.0833 4.75002 16.0833 5.04169C16.0833 5.33335 15.9826 5.57988 15.7812 5.78127C15.5798 5.98266 15.3333 6.08335 15.0416 6.08335ZM15.0416 14C14.75 14 14.5034 13.8993 14.302 13.6979C14.1007 13.4965 14 13.25 14 12.9584C14 12.6667 14.1007 12.4202 14.302 12.2188C14.5034 12.0174 14.75 11.9167 15.0416 11.9167C15.3333 11.9167 15.5798 12.0174 15.7812 12.2188C15.9826 12.4202 16.0833 12.6667 16.0833 12.9584C16.0833 13.25 15.9826 13.4965 15.7812 13.6979C15.5798 13.8993 15.3333 14 15.0416 14ZM16.2916 10.0417C16 10.0417 15.7534 9.94099 15.552 9.7396C15.3507 9.53821 15.25 9.29169 15.25 9.00002C15.25 8.70835 15.3507 8.46183 15.552 8.26044C15.7534 8.05905 16 7.95835 16.2916 7.95835C16.5833 7.95835 16.8298 8.05905 17.0312 8.26044C17.2326 8.46183 17.3333 8.70835 17.3333 9.00002C17.3333 9.29169 17.2326 9.53821 17.0312 9.7396C16.8298 9.94099 16.5833 10.0417 16.2916 10.0417ZM8.99996 17.3334C7.84718 17.3334 6.76385 17.1146 5.74996 16.6771C4.73607 16.2396 3.85413 15.6459 3.10413 14.8959C2.35413 14.1459 1.76038 13.2639 1.32288 12.25C0.885376 11.2361 0.666626 10.1528 0.666626 9.00002C0.666626 7.84724 0.885376 6.76391 1.32288 5.75002C1.76038 4.73613 2.35413 3.85419 3.10413 3.10419C3.85413 2.35419 4.73607 1.76044 5.74996 1.32294C6.76385 0.885437 7.84718 0.666687 8.99996 0.666687V2.33335C7.13885 2.33335 5.56246 2.97919 4.27079 4.27085C2.97913 5.56252 2.33329 7.13891 2.33329 9.00002C2.33329 10.8611 2.97913 12.4375 4.27079 13.7292C5.56246 15.0209 7.13885 15.6667 8.99996 15.6667V17.3334ZM8.99996 10.6667C8.54163 10.6667 8.14927 10.5035 7.82288 10.1771C7.49649 9.85071 7.33329 9.45835 7.33329 9.00002C7.33329 8.93058 7.33676 8.85766 7.34371 8.78127C7.35065 8.70488 7.36801 8.63196 7.39579 8.56252L5.66663 6.83335L6.83329 5.66669L8.56246 7.39585C8.61801 7.38196 8.76385 7.36113 8.99996 7.33335C9.45829 7.33335 9.85065 7.49655 10.177 7.82294C10.5034 8.14933 10.6666 8.54169 10.6666 9.00002C10.6666 9.45835 10.5034 9.85071 10.177 10.1771C9.85065 10.5035 9.45829 10.6667 8.99996 10.6667Z" fill="#40A1EA" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="font-bold">{trainingContent.exams.length}</p>
|
||||||
|
<p>Exams Selected</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-center gap-4">
|
||||||
|
<div className="flex w-14 h-14 bg-[#F5F5F5] items-center justify-center rounded-xl border border-[#DBDBDB]">
|
||||||
|
<RiArrowRightUpLine color={"#22E1B3"} size={gridView ? 28 : 26} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="font-bold">{highestScore}%</p>
|
||||||
|
<p>Highest Score</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={columnClasses}>
|
||||||
|
<div className="flex flex-row items-center gap-4">
|
||||||
|
<div className="flex w-14 h-14 bg-[#F5F5F5] items-center justify-center rounded-xl border border-[#DBDBDB]">
|
||||||
|
<FaChartLine color={"#40A1EA"} size={gridView ? 24 : 26} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="font-bold">{averageScore}%</p>
|
||||||
|
<p>Average Score</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-center gap-4">
|
||||||
|
<div className="flex w-14 h-14 bg-[#F5F5F5] items-center justify-center rounded-xl border border-[#DBDBDB]">
|
||||||
|
<RiArrowLeftDownLine color={"#E13922"} size={gridView ? 28 : 26} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="font-bold">{lowestScore}%</p>
|
||||||
|
<p>Lowest Score</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{gridView && (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 -lg:hidden">
|
||||||
|
<div className="flex w-14 h-14 bg-[#F5F5F5] items-center justify-center rounded-xl border border-[#DBDBDB]">
|
||||||
|
<GiLightBulb color={"#FFCC00"} size={28} />
|
||||||
|
</div>
|
||||||
|
<p><span className="font-bold">{trainingContent.tip_ids.length}</span> Tips</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrainingScore;
|
||||||
File diff suppressed because it is too large
Load Diff
31
src/components/ui/popover.tsx
Normal file
31
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
|
||||||
|
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border border-neutral-200 bg-white p-4 text-neutral-950 shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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 />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ import {usePDFDownload} from "@/hooks/usePDFDownload";
|
|||||||
import {useAssignmentArchive} from "@/hooks/useAssignmentArchive";
|
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 {User} from "@/interfaces/user";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
users: User[];
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
allowDownload?: boolean;
|
allowDownload?: boolean;
|
||||||
reload?: Function;
|
reload?: Function;
|
||||||
allowArchive?: boolean;
|
allowArchive?: boolean;
|
||||||
allowUnarchive?: boolean;
|
allowUnarchive?: boolean;
|
||||||
|
allowExcelDownload?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssignmentCard({
|
export default function AssignmentCard({
|
||||||
@@ -34,8 +38,11 @@ export default function AssignmentCard({
|
|||||||
reload,
|
reload,
|
||||||
allowArchive,
|
allowArchive,
|
||||||
allowUnarchive,
|
allowUnarchive,
|
||||||
|
allowExcelDownload,
|
||||||
|
users,
|
||||||
}: Assignment & Props) {
|
}: Assignment & Props) {
|
||||||
const renderPdfIcon = usePDFDownload("assignments");
|
const renderPdfIcon = usePDFDownload("assignments");
|
||||||
|
const renderExcelIcon = usePDFDownload("assignments", "excel");
|
||||||
const renderArchiveIcon = useAssignmentArchive(id, reload);
|
const renderArchiveIcon = useAssignmentArchive(id, reload);
|
||||||
const renderUnarchiveIcon = useAssignmentUnarchive(id, reload);
|
const renderUnarchiveIcon = useAssignmentUnarchive(id, reload);
|
||||||
|
|
||||||
@@ -60,6 +67,7 @@ export default function AssignmentCard({
|
|||||||
<h3 className="text-xl font-semibold">{name}</h3>
|
<h3 className="text-xl font-semibold">{name}</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{allowDownload && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
{allowDownload && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
|
{allowExcelDownload && renderExcelIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
{allowArchive && !archived && renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
{allowArchive && !archived && renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
{allowUnarchive && archived && renderUnarchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
{allowUnarchive && archived && renderUnarchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
||||||
</div>
|
</div>
|
||||||
@@ -72,11 +80,14 @@ export default function AssignmentCard({
|
|||||||
textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"}
|
textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="flex justify-between gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
|
<span className="flex justify-between gap-1">
|
||||||
<span>-</span>
|
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>-</span>
|
||||||
</span>
|
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
|
</span>
|
||||||
|
<span>Assigner: {getUserName(users.find((x) => x.id === assigner))}</span>
|
||||||
|
</div>
|
||||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||||
{uniqBy(exams, (x) => x.module).map(({module}) => (
|
{uniqBy(exams, (x) => x.module).map(({module}) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {getExamById} from "@/utils/exams";
|
|||||||
import {sortByModule} from "@/utils/moduleUtils";
|
import {sortByModule} from "@/utils/moduleUtils";
|
||||||
import {calculateBandScore} from "@/utils/score";
|
import {calculateBandScore} from "@/utils/score";
|
||||||
import {convertToUserSolutions} from "@/utils/stats";
|
import {convertToUserSolutions} from "@/utils/stats";
|
||||||
|
import {getUserName} from "@/utils/users";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize, uniqBy} from "lodash";
|
import {capitalize, uniqBy} from "lodash";
|
||||||
@@ -241,13 +242,16 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
|||||||
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
</div>
|
</div>
|
||||||
<span>
|
<div className="flex flex-col gap-2">
|
||||||
Assignees:{" "}
|
<span>
|
||||||
{users
|
Assignees:{" "}
|
||||||
.filter((u) => assignment?.assignees.includes(u.id))
|
{users
|
||||||
.map((u) => `${u.name} (${u.email})`)
|
.filter((u) => assignment?.assignees.includes(u.id))
|
||||||
.join(", ")}
|
.map((u) => `${u.name} (${u.email})`)
|
||||||
</span>
|
.join(", ")}
|
||||||
|
</span>
|
||||||
|
<span>Assigner: {getUserName(users.find((x) => x.id === assignment?.assigner))}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xl font-bold">Average Scores</span>
|
<span className="text-xl font-bold">Average Scores</span>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
34
src/dashboards/MasterStatistical.tsx
Normal file
34
src/dashboards/MasterStatistical.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {CorporateUser} from "@/interfaces/user";
|
||||||
|
import {BsBank, BsPersonFill} from "react-icons/bs";
|
||||||
|
import IconCard from "./IconCard";
|
||||||
|
import useAssignmentsCorporates from "@/hooks/useAssignmentCorporates";
|
||||||
|
interface Props {
|
||||||
|
users: CorporateUser[];
|
||||||
|
}
|
||||||
|
const MasterStatistical = (props: Props) => {
|
||||||
|
const {users} = props;
|
||||||
|
|
||||||
|
const usersList = React.useMemo(() => users.map((x) => x.id), [users]);
|
||||||
|
|
||||||
|
const {assignments} = useAssignmentsCorporates({corporates: usersList});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2 items-center text-center">
|
||||||
|
<IconCard Icon={BsBank} label="Consolidate" value={0} color="purple" onClick={() => console.log("clicked")} />
|
||||||
|
{users.map((group) => (
|
||||||
|
<IconCard
|
||||||
|
key={group.id}
|
||||||
|
Icon={BsBank}
|
||||||
|
label={group.corporateInformation?.companyInformation?.name}
|
||||||
|
value={0}
|
||||||
|
color="purple"
|
||||||
|
onClick={() => console.log("clicked", group)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<IconCard onClick={() => console.log("clicked")} Icon={BsPersonFill} label="Consolidate Highest Student" color="purple" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MasterStatistical;
|
||||||
@@ -2,477 +2,408 @@
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useStats from "@/hooks/useStats";
|
import useStats from "@/hooks/useStats";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import { CorporateUser, Group, Stat, User } from "@/interfaces/user";
|
import {CorporateUser, Group, Stat, User} from "@/interfaces/user";
|
||||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
import { dateSorter } from "@/utils";
|
import {dateSorter} from "@/utils";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { useEffect, useState } from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {
|
import {
|
||||||
BsArrowLeft,
|
BsArrowLeft,
|
||||||
BsArrowRepeat,
|
BsArrowRepeat,
|
||||||
BsClipboard2Data,
|
BsClipboard2Data,
|
||||||
BsClipboard2DataFill,
|
BsClipboard2DataFill,
|
||||||
BsClipboard2Heart,
|
BsClipboard2Heart,
|
||||||
BsClipboard2X,
|
BsClipboard2X,
|
||||||
BsClipboardPulse,
|
BsClipboardPulse,
|
||||||
BsClock,
|
BsClock,
|
||||||
BsEnvelopePaper,
|
BsEnvelopePaper,
|
||||||
BsGlobeCentralSouthAsia,
|
BsGlobeCentralSouthAsia,
|
||||||
BsPaperclip,
|
BsPaperclip,
|
||||||
BsPeople,
|
BsPeople,
|
||||||
BsPerson,
|
BsPerson,
|
||||||
BsPersonAdd,
|
BsPersonAdd,
|
||||||
BsPersonFill,
|
BsPersonFill,
|
||||||
BsPersonFillGear,
|
BsPersonFillGear,
|
||||||
BsPersonGear,
|
BsPersonGear,
|
||||||
BsPlus,
|
BsPlus,
|
||||||
BsRepeat,
|
BsRepeat,
|
||||||
BsRepeat1,
|
BsRepeat1,
|
||||||
} from "react-icons/bs";
|
} from "react-icons/bs";
|
||||||
import UserCard from "@/components/UserCard";
|
import UserCard from "@/components/UserCard";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
|
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
|
||||||
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||||
import { Module } from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import { groupByExam } from "@/utils/stats";
|
import {groupByExam} from "@/utils/stats";
|
||||||
import IconCard from "./IconCard";
|
import IconCard from "./IconCard";
|
||||||
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
import GroupList from "@/pages/(admin)/Lists/GroupList";
|
||||||
import useAssignments from "@/hooks/useAssignments";
|
import useAssignments from "@/hooks/useAssignments";
|
||||||
import { Assignment } from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
import AssignmentCard from "./AssignmentCard";
|
import AssignmentCard from "./AssignmentCard";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import AssignmentCreator from "./AssignmentCreator";
|
import AssignmentCreator from "./AssignmentCreator";
|
||||||
import AssignmentView from "./AssignmentView";
|
import AssignmentView from "./AssignmentView";
|
||||||
import { getUserCorporate } from "@/utils/groups";
|
import {getUserCorporate} from "@/utils/groups";
|
||||||
|
import {checkAccess} from "@/utils/permissions";
|
||||||
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TeacherDashboard({ user }: Props) {
|
export default function TeacherDashboard({user}: Props) {
|
||||||
const [page, setPage] = useState("");
|
const [page, setPage] = useState("");
|
||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
|
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
|
||||||
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
||||||
const [corporateUserToShow, setCorporateUserToShow] =
|
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
|
||||||
useState<CorporateUser>();
|
|
||||||
|
|
||||||
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 {
|
const {permissions} = usePermissions(user.id);
|
||||||
assignments,
|
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id});
|
||||||
isLoading: isAssignmentsLoading,
|
|
||||||
reload: reloadAssignments,
|
|
||||||
} = useAssignments({ assigner: user.id });
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowModal(!!selectedUser && page === "");
|
setShowModal(!!selectedUser && page === "");
|
||||||
}, [selectedUser, page]);
|
}, [selectedUser, page]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getUserCorporate(user.id).then(setCorporateUserToShow);
|
getUserCorporate(user.id).then(setCorporateUserToShow);
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const studentFilter = (user: User) =>
|
const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id);
|
||||||
user.type === "student" &&
|
|
||||||
groups.flatMap((g) => g.participants).includes(user.id);
|
|
||||||
|
|
||||||
const getStatsByStudent = (user: User) =>
|
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
||||||
stats.filter((s) => s.user === user.id);
|
|
||||||
|
|
||||||
const UserDisplay = (displayUser: User) => (
|
const UserDisplay = (displayUser: User) => (
|
||||||
<div
|
<div
|
||||||
onClick={() => setSelectedUser(displayUser)}
|
onClick={() => setSelectedUser(displayUser)}
|
||||||
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"
|
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
||||||
>
|
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
||||||
<img
|
<div className="flex flex-col gap-1 items-start">
|
||||||
src={displayUser.profilePicture}
|
<span>{displayUser.name}</span>
|
||||||
alt={displayUser.name}
|
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||||
className="rounded-full w-10 h-10"
|
</div>
|
||||||
/>
|
</div>
|
||||||
<div className="flex flex-col gap-1 items-start">
|
);
|
||||||
<span>{displayUser.name}</span>
|
|
||||||
<span className="text-sm opacity-75">{displayUser.email}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const StudentsList = () => {
|
const StudentsList = () => {
|
||||||
const filter = (x: User) =>
|
const filter = (x: User) =>
|
||||||
x.type === "student" &&
|
x.type === "student" &&
|
||||||
(!!selectedUser
|
(!!selectedUser
|
||||||
? groups
|
? groups
|
||||||
.filter((g) => g.admin === selectedUser.id)
|
.filter((g) => g.admin === selectedUser.id)
|
||||||
.flatMap((g) => g.participants)
|
.flatMap((g) => g.participants)
|
||||||
.includes(x.id) || false
|
.includes(x.id) || false
|
||||||
: groups.flatMap((g) => g.participants).includes(x.id));
|
: groups.flatMap((g) => g.participants).includes(x.id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserList
|
<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">Students ({total})</h2>
|
||||||
<h2 className="text-2xl font-semibold">Students ({total})</h2>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
);
|
||||||
);
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const GroupsList = () => {
|
const GroupsList = () => {
|
||||||
const filter = (x: Group) =>
|
const filter = (x: Group) => x.admin === user.id;
|
||||||
x.admin === user.id || x.participants.includes(user.id);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<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">Groups ({groups.filter(filter).length})</h2>
|
||||||
<h2 className="text-2xl font-semibold">
|
</div>
|
||||||
Groups ({groups.filter(filter).length})
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<GroupList user={user} />
|
<GroupList user={user} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const averageLevelCalculator = (studentStats: Stat[]) => {
|
const averageLevelCalculator = (studentStats: Stat[]) => {
|
||||||
const formattedStats = studentStats
|
const formattedStats = studentStats
|
||||||
.map((s) => ({
|
.map((s) => ({
|
||||||
focus: users.find((u) => u.id === s.user)?.focus,
|
focus: users.find((u) => u.id === s.user)?.focus,
|
||||||
score: s.score,
|
score: s.score,
|
||||||
module: s.module,
|
module: s.module,
|
||||||
}))
|
}))
|
||||||
.filter((f) => !!f.focus);
|
.filter((f) => !!f.focus);
|
||||||
const bandScores = formattedStats.map((s) => ({
|
const bandScores = formattedStats.map((s) => ({
|
||||||
module: s.module,
|
module: s.module,
|
||||||
level: calculateBandScore(
|
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
|
||||||
s.score.correct,
|
}));
|
||||||
s.score.total,
|
|
||||||
s.module,
|
|
||||||
s.focus!
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const levels: { [key in Module]: number } = {
|
const levels: {[key in Module]: number} = {
|
||||||
reading: 0,
|
reading: 0,
|
||||||
listening: 0,
|
listening: 0,
|
||||||
writing: 0,
|
writing: 0,
|
||||||
speaking: 0,
|
speaking: 0,
|
||||||
level: 0,
|
level: 0,
|
||||||
};
|
};
|
||||||
bandScores.forEach((b) => (levels[b.module] += b.level));
|
bandScores.forEach((b) => (levels[b.module] += b.level));
|
||||||
|
|
||||||
return calculateAverageLevel(levels);
|
return calculateAverageLevel(levels);
|
||||||
};
|
};
|
||||||
|
|
||||||
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()) && a.assignees.length > a.results.length;
|
||||||
moment(a.startDate).isBefore(moment()) &&
|
const pastFilter = (a: Assignment) => (moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length) && !a.archived;
|
||||||
a.assignees.length > a.results.length;
|
const archivedFilter = (a: Assignment) => a.archived;
|
||||||
const pastFilter = (a: Assignment) =>
|
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
|
||||||
(moment(a.endDate).isBefore(moment()) ||
|
|
||||||
a.assignees.length === a.results.length) &&
|
|
||||||
!a.archived;
|
|
||||||
const archivedFilter = (a: Assignment) => a.archived;
|
|
||||||
const futureFilter = (a: Assignment) =>
|
|
||||||
moment(a.startDate).isAfter(moment());
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AssignmentView
|
<AssignmentView
|
||||||
isOpen={!!selectedAssignment && !isCreatingAssignment}
|
isOpen={!!selectedAssignment && !isCreatingAssignment}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setSelectedAssignment(undefined);
|
setSelectedAssignment(undefined);
|
||||||
setIsCreatingAssignment(false);
|
setIsCreatingAssignment(false);
|
||||||
reloadAssignments();
|
reloadAssignments();
|
||||||
}}
|
}}
|
||||||
assignment={selectedAssignment}
|
assignment={selectedAssignment}
|
||||||
/>
|
/>
|
||||||
<AssignmentCreator
|
<AssignmentCreator
|
||||||
assignment={selectedAssignment}
|
assignment={selectedAssignment}
|
||||||
groups={groups.filter(
|
groups={groups.filter((x) => x.admin === user.id || x.participants.includes(user.id))}
|
||||||
(x) => x.admin === user.id || x.participants.includes(user.id)
|
users={users.filter(
|
||||||
)}
|
(x) =>
|
||||||
users={users.filter(
|
x.type === "student" &&
|
||||||
(x) =>
|
(!!selectedUser
|
||||||
x.type === "student" &&
|
? groups
|
||||||
(!!selectedUser
|
.filter((g) => g.admin === selectedUser.id)
|
||||||
? groups
|
.flatMap((g) => g.participants)
|
||||||
.filter((g) => g.admin === selectedUser.id)
|
.includes(x.id)
|
||||||
.flatMap((g) => g.participants)
|
: groups.flatMap((g) => g.participants).includes(x.id)),
|
||||||
.includes(x.id) || false
|
)}
|
||||||
: groups.flatMap((g) => g.participants).includes(x.id))
|
assigner={user.id}
|
||||||
)}
|
isCreating={isCreatingAssignment}
|
||||||
assigner={user.id}
|
cancelCreation={() => {
|
||||||
isCreating={isCreatingAssignment}
|
setIsCreatingAssignment(false);
|
||||||
cancelCreation={() => {
|
setSelectedAssignment(undefined);
|
||||||
setIsCreatingAssignment(false);
|
reloadAssignments();
|
||||||
setSelectedAssignment(undefined);
|
}}
|
||||||
reloadAssignments();
|
/>
|
||||||
}}
|
<div className="w-full flex justify-between items-center">
|
||||||
/>
|
<div
|
||||||
<div className="w-full flex justify-between items-center">
|
onClick={() => setPage("")}
|
||||||
<div
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
onClick={() => setPage("")}
|
<BsArrowLeft className="text-xl" />
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
<span>Back</span>
|
||||||
>
|
</div>
|
||||||
<BsArrowLeft className="text-xl" />
|
<div
|
||||||
<span>Back</span>
|
onClick={reloadAssignments}
|
||||||
</div>
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
<div
|
<span>Reload</span>
|
||||||
onClick={reloadAssignments}
|
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
</div>
|
||||||
>
|
</div>
|
||||||
<span>Reload</span>
|
<section className="flex flex-col gap-4">
|
||||||
<BsArrowRepeat
|
<h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeFilter).length})</h2>
|
||||||
className={clsx(
|
<div className="flex flex-wrap gap-2">
|
||||||
"text-xl",
|
{assignments.filter(activeFilter).map((a) => (
|
||||||
isAssignmentsLoading && "animate-spin"
|
<AssignmentCard {...a} users={users} onClick={() => setSelectedAssignment(a)} key={a.id} />
|
||||||
)}
|
))}
|
||||||
/>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
<section className="flex flex-col gap-4">
|
||||||
<section className="flex flex-col gap-4">
|
<h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureFilter).length})</h2>
|
||||||
<h2 className="text-2xl font-semibold">
|
<div className="flex flex-wrap gap-2">
|
||||||
Active Assignments ({assignments.filter(activeFilter).length})
|
<div
|
||||||
</h2>
|
onClick={() => setIsCreatingAssignment(true)}
|
||||||
<div className="flex flex-wrap gap-2">
|
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
|
||||||
{assignments.filter(activeFilter).map((a) => (
|
<BsPlus className="text-6xl" />
|
||||||
<AssignmentCard
|
<span className="text-lg">New Assignment</span>
|
||||||
{...a}
|
</div>
|
||||||
onClick={() => setSelectedAssignment(a)}
|
{assignments.filter(futureFilter).map((a) => (
|
||||||
key={a.id}
|
<AssignmentCard
|
||||||
/>
|
{...a}
|
||||||
))}
|
users={users}
|
||||||
</div>
|
onClick={() => {
|
||||||
</section>
|
setSelectedAssignment(a);
|
||||||
<section className="flex flex-col gap-4">
|
setIsCreatingAssignment(true);
|
||||||
<h2 className="text-2xl font-semibold">
|
}}
|
||||||
Planned Assignments ({assignments.filter(futureFilter).length})
|
key={a.id}
|
||||||
</h2>
|
/>
|
||||||
<div className="flex flex-wrap gap-2">
|
))}
|
||||||
<div
|
</div>
|
||||||
onClick={() => setIsCreatingAssignment(true)}
|
</section>
|
||||||
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 className="flex flex-col gap-4">
|
||||||
>
|
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
|
||||||
<BsPlus className="text-6xl" />
|
<div className="flex flex-wrap gap-2">
|
||||||
<span className="text-lg">New Assignment</span>
|
{assignments.filter(pastFilter).map((a) => (
|
||||||
</div>
|
<AssignmentCard
|
||||||
{assignments.filter(futureFilter).map((a) => (
|
{...a}
|
||||||
<AssignmentCard
|
users={users}
|
||||||
{...a}
|
onClick={() => setSelectedAssignment(a)}
|
||||||
onClick={() => {
|
key={a.id}
|
||||||
setSelectedAssignment(a);
|
allowDownload
|
||||||
setIsCreatingAssignment(true);
|
reload={reloadAssignments}
|
||||||
}}
|
allowArchive
|
||||||
key={a.id}
|
allowExcelDownload
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="flex flex-col gap-4">
|
<section className="flex flex-col gap-4">
|
||||||
<h2 className="text-2xl font-semibold">
|
<h2 className="text-2xl font-semibold">Archived Assignments ({assignments.filter(archivedFilter).length})</h2>
|
||||||
Past Assignments ({assignments.filter(pastFilter).length})
|
<div className="flex flex-wrap gap-2">
|
||||||
</h2>
|
{assignments.filter(archivedFilter).map((a) => (
|
||||||
<div className="flex flex-wrap gap-2">
|
<AssignmentCard
|
||||||
{assignments.filter(pastFilter).map((a) => (
|
{...a}
|
||||||
<AssignmentCard
|
users={users}
|
||||||
{...a}
|
onClick={() => setSelectedAssignment(a)}
|
||||||
onClick={() => setSelectedAssignment(a)}
|
key={a.id}
|
||||||
key={a.id}
|
allowDownload
|
||||||
allowDownload
|
reload={reloadAssignments}
|
||||||
reload={reloadAssignments}
|
allowUnarchive
|
||||||
allowArchive
|
allowExcelDownload
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="flex flex-col gap-4">
|
</>
|
||||||
<h2 className="text-2xl font-semibold">
|
);
|
||||||
Archived Assignments ({assignments.filter(archivedFilter).length})
|
};
|
||||||
</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{assignments.filter(archivedFilter).map((a) => (
|
|
||||||
<AssignmentCard
|
|
||||||
{...a}
|
|
||||||
onClick={() => setSelectedAssignment(a)}
|
|
||||||
key={a.id}
|
|
||||||
allowDownload
|
|
||||||
reload={reloadAssignments}
|
|
||||||
allowUnarchive
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const DefaultDashboard = () => (
|
const DefaultDashboard = () => (
|
||||||
<>
|
<>
|
||||||
{corporateUserToShow && (
|
{corporateUserToShow && (
|
||||||
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
|
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
|
||||||
Linked to:{" "}
|
Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b>
|
||||||
<b>
|
</div>
|
||||||
{corporateUserToShow?.corporateInformation?.companyInformation
|
)}
|
||||||
.name || corporateUserToShow.name}
|
<section
|
||||||
</b>
|
className={clsx(
|
||||||
</div>
|
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center",
|
||||||
)}
|
!!corporateUserToShow && "mt-12 xl:mt-6",
|
||||||
<section
|
)}>
|
||||||
className={clsx(
|
<IconCard
|
||||||
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center",
|
onClick={() => setPage("students")}
|
||||||
!!corporateUserToShow && "mt-12 xl:mt-6"
|
Icon={BsPersonFill}
|
||||||
)}
|
label="Students"
|
||||||
>
|
value={users.filter(studentFilter).length}
|
||||||
<IconCard
|
color="purple"
|
||||||
onClick={() => setPage("students")}
|
/>
|
||||||
Icon={BsPersonFill}
|
<IconCard
|
||||||
label="Students"
|
Icon={BsClipboard2Data}
|
||||||
value={users.filter(studentFilter).length}
|
label="Exams Performed"
|
||||||
color="purple"
|
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
|
||||||
/>
|
color="purple"
|
||||||
<IconCard
|
/>
|
||||||
Icon={BsClipboard2Data}
|
<IconCard
|
||||||
label="Exams Performed"
|
Icon={BsPaperclip}
|
||||||
value={
|
label="Average Level"
|
||||||
stats.filter((s) =>
|
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
||||||
groups.flatMap((g) => g.participants).includes(s.user)
|
color="purple"
|
||||||
).length
|
/>
|
||||||
}
|
{checkAccess(user, ["teacher", "developer"], permissions, "viewGroup") && (
|
||||||
color="purple"
|
<IconCard
|
||||||
/>
|
Icon={BsPeople}
|
||||||
<IconCard
|
label="Groups"
|
||||||
Icon={BsPaperclip}
|
value={groups.filter((x) => x.admin === user.id).length}
|
||||||
label="Average Level"
|
color="purple"
|
||||||
value={averageLevelCalculator(
|
onClick={() => setPage("groups")}
|
||||||
stats.filter((s) =>
|
/>
|
||||||
groups.flatMap((g) => g.participants).includes(s.user)
|
)}
|
||||||
)
|
<div
|
||||||
).toFixed(1)}
|
onClick={() => setPage("assignments")}
|
||||||
color="purple"
|
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||||
/>
|
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
||||||
<IconCard
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
Icon={BsPeople}
|
<span className="text-lg">Assignments</span>
|
||||||
label="Groups"
|
<span className="font-semibold text-mti-purple-light">{assignments.filter((a) => !a.archived).length}</span>
|
||||||
value={groups.length}
|
</span>
|
||||||
color="purple"
|
</div>
|
||||||
onClick={() => setPage("groups")}
|
</section>
|
||||||
/>
|
|
||||||
<div
|
|
||||||
onClick={() => setPage("assignments")}
|
|
||||||
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300"
|
|
||||||
>
|
|
||||||
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
|
||||||
<span className="flex flex-col gap-1 items-center text-xl">
|
|
||||||
<span className="text-lg">Assignments</span>
|
|
||||||
<span className="font-semibold text-mti-purple-light">
|
|
||||||
{assignments.filter((a) => !a.archived).length}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
|
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span className="p-4">Latest students</span>
|
<span className="p-4">Latest students</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(studentFilter)
|
.filter(studentFilter)
|
||||||
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...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">Highest level students</span>
|
<span className="p-4">Highest level students</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(studentFilter)
|
.filter(studentFilter)
|
||||||
.sort(
|
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
||||||
(a, b) =>
|
.map((x) => (
|
||||||
calculateAverageLevel(b.levels) -
|
<UserDisplay key={x.id} {...x} />
|
||||||
calculateAverageLevel(a.levels)
|
))}
|
||||||
)
|
</div>
|
||||||
.map((x) => (
|
</div>
|
||||||
<UserDisplay key={x.id} {...x} />
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
))}
|
<span className="p-4">Highest exam count students</span>
|
||||||
</div>
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
</div>
|
{users
|
||||||
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
.filter(studentFilter)
|
||||||
<span className="p-4">Highest exam count students</span>
|
.sort(
|
||||||
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
(a, b) =>
|
||||||
{users
|
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
||||||
.filter(studentFilter)
|
)
|
||||||
.sort(
|
.map((x) => (
|
||||||
(a, b) =>
|
<UserDisplay key={x.id} {...x} />
|
||||||
Object.keys(groupByExam(getStatsByStudent(b))).length -
|
))}
|
||||||
Object.keys(groupByExam(getStatsByStudent(a))).length
|
</div>
|
||||||
)
|
</div>
|
||||||
.map((x) => (
|
</section>
|
||||||
<UserDisplay key={x.id} {...x} />
|
</>
|
||||||
))}
|
);
|
||||||
</div>
|
|
||||||
</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 === "students" && <StudentsList />}
|
||||||
user={selectedUser}
|
{page === "groups" && <GroupsList />}
|
||||||
/>
|
{page === "assignments" && <AssignmentsPage />}
|
||||||
</div>
|
{page === "" && <DefaultDashboard />}
|
||||||
)}
|
</>
|
||||||
</>
|
);
|
||||||
</Modal>
|
|
||||||
{page === "students" && <StudentsList />}
|
|
||||||
{page === "groups" && <GroupsList />}
|
|
||||||
{page === "assignments" && <AssignmentsPage />}
|
|
||||||
{page === "" && <DefaultDashboard />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,221 +0,0 @@
|
|||||||
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
|
||||||
import {renderExercise} from "@/components/Exercises";
|
|
||||||
import Button from "@/components/Low/Button";
|
|
||||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
|
||||||
import {renderSolution} from "@/components/Solutions";
|
|
||||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
|
||||||
import {LevelExam, LevelPart, UserSolution, WritingExam} from "@/interfaces/exam";
|
|
||||||
import useExamStore from "@/stores/examStore";
|
|
||||||
import {defaultUserSolutions} from "@/utils/exams";
|
|
||||||
import {countExercises} from "@/utils/moduleUtils";
|
|
||||||
import {mdiArrowRight} from "@mdi/js";
|
|
||||||
import Icon from "@mdi/react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import {Fragment, useEffect, useState} from "react";
|
|
||||||
import {BsChevronDown, BsChevronUp} from "react-icons/bs";
|
|
||||||
import {toast} from "react-toastify";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
exam: LevelExam;
|
|
||||||
showSolutions?: boolean;
|
|
||||||
onFinish: (userSolutions: UserSolution[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TextComponent({part}: {part: LevelPart}) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2 w-full">
|
|
||||||
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
|
||||||
{!!part.context &&
|
|
||||||
part.context
|
|
||||||
.split(/\n|(\\n)/g)
|
|
||||||
.filter((x) => x && x.length > 0 && x !== "\\n")
|
|
||||||
.map((line, index) => (
|
|
||||||
<Fragment key={index}>
|
|
||||||
<p key={index}>{line}</p>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Level({exam, showSolutions = false, onFinish}: Props) {
|
|
||||||
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]);
|
|
||||||
const [showBlankModal, setShowBlankModal] = useState(false);
|
|
||||||
|
|
||||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
|
||||||
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
|
||||||
const {partIndex, setPartIndex} = useExamStore((state) => state);
|
|
||||||
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
|
||||||
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]);
|
|
||||||
|
|
||||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasExamEnded && exerciseIndex === -1) {
|
|
||||||
setExerciseIndex(exerciseIndex + 1);
|
|
||||||
}
|
|
||||||
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
|
||||||
|
|
||||||
const confirmFinishModule = (keepGoing?: boolean) => {
|
|
||||||
if (!keepGoing) {
|
|
||||||
setShowBlankModal(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onFinish(userSolutions);
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextExercise = (solution?: UserSolution) => {
|
|
||||||
scrollToTop();
|
|
||||||
if (solution) {
|
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (storeQuestionIndex > 0) {
|
|
||||||
const exercise = getExercise();
|
|
||||||
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: storeQuestionIndex}]);
|
|
||||||
}
|
|
||||||
setStoreQuestionIndex(0);
|
|
||||||
|
|
||||||
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
|
||||||
setExerciseIndex(exerciseIndex + 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
|
||||||
setPartIndex(partIndex + 1);
|
|
||||||
setExerciseIndex(!!exam.parts[partIndex + 1].context ? -1 : 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
solution &&
|
|
||||||
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
|
|
||||||
(x) => x === 0,
|
|
||||||
) &&
|
|
||||||
!showSolutions &&
|
|
||||||
!hasExamEnded
|
|
||||||
) {
|
|
||||||
setShowBlankModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasExamEnded(false);
|
|
||||||
|
|
||||||
if (solution) {
|
|
||||||
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
|
|
||||||
} else {
|
|
||||||
onFinish(userSolutions);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const previousExercise = (solution?: UserSolution) => {
|
|
||||||
scrollToTop();
|
|
||||||
if (solution) {
|
|
||||||
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (storeQuestionIndex > 0) {
|
|
||||||
const exercise = getExercise();
|
|
||||||
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: storeQuestionIndex}]);
|
|
||||||
}
|
|
||||||
setStoreQuestionIndex(0);
|
|
||||||
|
|
||||||
setExerciseIndex(exerciseIndex - 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getExercise = () => {
|
|
||||||
const exercise = exam.parts[partIndex].exercises[exerciseIndex];
|
|
||||||
return {
|
|
||||||
...exercise,
|
|
||||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateExerciseIndex = () => {
|
|
||||||
if (partIndex === 0)
|
|
||||||
return (
|
|
||||||
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex + multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
|
|
||||||
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
|
|
||||||
return (
|
|
||||||
exercisesDone +
|
|
||||||
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
|
|
||||||
storeQuestionIndex +
|
|
||||||
multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderText = () => (
|
|
||||||
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative py-8 px-16")}>
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col w-full gap-2">
|
|
||||||
<h4 className="text-xl font-semibold">
|
|
||||||
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
|
||||||
</h4>
|
|
||||||
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
|
|
||||||
</div>
|
|
||||||
<TextComponent part={exam.parts[partIndex]} />
|
|
||||||
</>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col h-full w-full gap-8 items-center">
|
|
||||||
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
|
||||||
<ModuleTitle
|
|
||||||
minTimer={exam.minTimer}
|
|
||||||
exerciseIndex={calculateExerciseIndex()}
|
|
||||||
module="level"
|
|
||||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
|
||||||
disableTimer={showSolutions}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"mb-20 w-full",
|
|
||||||
partIndex > -1 && exerciseIndex > -1 && !!exam.parts[partIndex].context && "grid grid-cols-2 gap-4",
|
|
||||||
)}>
|
|
||||||
{partIndex > -1 && !!exam.parts[partIndex].context && renderText()}
|
|
||||||
|
|
||||||
{exerciseIndex > -1 &&
|
|
||||||
partIndex > -1 &&
|
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
|
||||||
!showSolutions &&
|
|
||||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
|
|
||||||
|
|
||||||
{exerciseIndex > -1 &&
|
|
||||||
partIndex > -1 &&
|
|
||||||
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
|
||||||
showSolutions &&
|
|
||||||
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
|
|
||||||
</div>
|
|
||||||
{exerciseIndex === -1 && partIndex > 0 && (
|
|
||||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
|
||||||
setPartIndex(partIndex - 1);
|
|
||||||
}}
|
|
||||||
className="max-w-[200px] w-full">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{exerciseIndex === -1 && partIndex === 0 && (
|
|
||||||
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
|
||||||
Start now
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
37
src/exams/Level/PartDivider.tsx
Normal file
37
src/exams/Level/PartDivider.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import { LevelPart, UserSolution } from "@/interfaces/exam";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
partIndex: number;
|
||||||
|
part: LevelPart // for now
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PartDivider: React.FC<Props> = ({ partIndex, part, onNext }) => {
|
||||||
|
|
||||||
|
const moduleIcon: { [key in Module]: ReactNode } = {
|
||||||
|
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
|
||||||
|
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
|
||||||
|
writing: <BsPen className="text-ielts-writing w-6 h-6" />,
|
||||||
|
speaking: <BsMegaphone className="text-ielts-speaking w-6 h-6" />,
|
||||||
|
level: <BsClipboard className="text-white w-6 h-6" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-3/6 h-fit border bg-white rounded-3xl p-12 gap-8">
|
||||||
|
{/** only level for now */}
|
||||||
|
<div className="flex flex-row gap-4 items-center"><div className="w-12 h-12 bg-ielts-level flex items-center justify-center rounded-lg">{moduleIcon["level"]}</div><p className="text-3xl">{`Part ${partIndex + 1}`}</p></div>
|
||||||
|
{part.intro!.split('\\n\\n').map((x, index) => <p key={`line-${index}`} className="text-2xl text-clip">{x}</p>)}
|
||||||
|
<div className="flex items-center justify-center mt-4">
|
||||||
|
<Button color="purple" onClick={() => onNext()} className="max-w-[200px] self-end w-full text-2xl">
|
||||||
|
{partIndex === 0 ? `Start now`: `Start Part ${partIndex + 1}`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PartDivider;
|
||||||
146
src/exams/Level/TextComponent.tsx
Normal file
146
src/exams/Level/TextComponent.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { LevelPart } from "@/interfaces/exam";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
part: LevelPart,
|
||||||
|
contextWord: string | undefined,
|
||||||
|
setContextWordLine: React.Dispatch<React.SetStateAction<number | undefined>>
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextComponent: React.FC<Props> = ({part, contextWord, setContextWordLine}) => {
|
||||||
|
const textRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const calculateLineNumbers = () => {
|
||||||
|
if (textRef.current) {
|
||||||
|
const computedStyle = window.getComputedStyle(textRef.current);
|
||||||
|
const containerWidth = textRef.current.clientWidth;
|
||||||
|
|
||||||
|
const offscreenElement = document.createElement('div');
|
||||||
|
offscreenElement.style.position = 'absolute';
|
||||||
|
offscreenElement.style.top = '-9999px';
|
||||||
|
offscreenElement.style.left = '-9999px';
|
||||||
|
offscreenElement.style.width = `${containerWidth}px`;
|
||||||
|
offscreenElement.style.font = computedStyle.font;
|
||||||
|
offscreenElement.style.lineHeight = computedStyle.lineHeight;
|
||||||
|
offscreenElement.style.whiteSpace = 'pre-wrap';
|
||||||
|
offscreenElement.style.wordWrap = 'break-word';
|
||||||
|
offscreenElement.style.textAlign = computedStyle.textAlign as CanvasTextAlign;
|
||||||
|
|
||||||
|
const paragraphs = part.context!.split('\n\n');
|
||||||
|
let currentLine = 1;
|
||||||
|
let contextWordLine: number | null = null;
|
||||||
|
const paragraphLineStarts: number[] = [];
|
||||||
|
|
||||||
|
paragraphs.forEach((paragraph, pIndex) => {
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.style.margin = '0';
|
||||||
|
p.style.padding = '0';
|
||||||
|
|
||||||
|
paragraph.split(/(\s+)/).forEach((word: string) => {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = word;
|
||||||
|
p.appendChild(span);
|
||||||
|
});
|
||||||
|
|
||||||
|
offscreenElement.appendChild(p);
|
||||||
|
|
||||||
|
if (pIndex < paragraphs.length - 1) {
|
||||||
|
const gap = document.createElement('div');
|
||||||
|
gap.style.height = '16px'; // gap-4
|
||||||
|
offscreenElement.appendChild(gap);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(offscreenElement);
|
||||||
|
|
||||||
|
let currentLineTop: number | undefined;
|
||||||
|
const elements = offscreenElement.querySelectorAll('p, div');
|
||||||
|
|
||||||
|
elements.forEach((element) => {
|
||||||
|
if (element.tagName === 'P') {
|
||||||
|
const spans = element.querySelectorAll<HTMLSpanElement>('span');
|
||||||
|
paragraphLineStarts.push(currentLine);
|
||||||
|
|
||||||
|
spans.forEach(span => {
|
||||||
|
const rect = span.getBoundingClientRect();
|
||||||
|
const top = rect.top;
|
||||||
|
|
||||||
|
if (currentLineTop === undefined || top > currentLineTop) {
|
||||||
|
if (currentLineTop !== undefined) {
|
||||||
|
currentLine++;
|
||||||
|
}
|
||||||
|
currentLineTop = top;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contextWord && contextWordLine === null && span.textContent?.includes(contextWord)) {
|
||||||
|
contextWordLine = currentLine;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (element.tagName === 'DIV') { // Gap
|
||||||
|
currentLine++;
|
||||||
|
currentLineTop = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (contextWordLine) {
|
||||||
|
setContextWordLine(contextWordLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.removeChild(offscreenElement);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
calculateLineNumbers();
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
calculateLineNumbers();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (textRef.current) {
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
resizeObserver.observe(textRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (textRef.current) {
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
resizeObserver.unobserve(textRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [part.context, 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 (
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
||||||
|
<div className="flex mt-2">
|
||||||
|
<div ref={textRef} className="h-fit ml-2 flex flex-col gap-4">
|
||||||
|
{part.context!.split('\n\n').map((line, index) => {
|
||||||
|
return <p key={`line-${index}`}><span className="mr-6">{index + 1}</span>{line}</p>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TextComponent;
|
||||||
442
src/exams/Level/index.tsx
Normal file
442
src/exams/Level/index.tsx
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
import QuestionsModal from "@/components/QuestionsModal";
|
||||||
|
import { renderExercise } from "@/components/Exercises";
|
||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
|
import { renderSolution } from "@/components/Solutions";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import { Exercise, FillBlanksMCOption, LevelExam, MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap, UserSolution } from "@/interfaces/exam";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import { countExercises } from "@/utils/moduleUtils";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { use, useEffect, useState } from "react";
|
||||||
|
import TextComponent from "./TextComponent";
|
||||||
|
import PartDivider from "./PartDivider";
|
||||||
|
import Timer from "@/components/Medium/Timer";
|
||||||
|
import { Stat } from "@/interfaces/user";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
exam: LevelExam;
|
||||||
|
showSolutions?: boolean;
|
||||||
|
onFinish: (userSolutions: UserSolution[]) => void;
|
||||||
|
editing?: boolean;
|
||||||
|
partDividers?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
|
||||||
|
return Array.isArray(words) && words.every(
|
||||||
|
word => word && typeof word === 'object' && 'id' in word && 'options' in word
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default function Level({ exam, showSolutions = false, onFinish, editing = false }: Props) {
|
||||||
|
const levelBgColor = "bg-ielts-level-light";
|
||||||
|
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]);
|
||||||
|
const [showQuestionsModal, setShowQuestionsModal] = useState(false);
|
||||||
|
|
||||||
|
const { setBgColor } = useExamStore((state) => state);
|
||||||
|
const { userSolutions, setUserSolutions } = useExamStore((state) => state);
|
||||||
|
const { hasExamEnded, setHasExamEnded } = useExamStore((state) => state);
|
||||||
|
const { partIndex, setPartIndex } = useExamStore((state) => state);
|
||||||
|
const { exerciseIndex, setExerciseIndex } = useExamStore((state) => state);
|
||||||
|
const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]);
|
||||||
|
const [shuffleMaps, setShuffleMaps] = useExamStore((state) => [state.shuffleMaps, state.setShuffleMaps])
|
||||||
|
const [currentExercise, setCurrentExercise] = useState<Exercise>();
|
||||||
|
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions);
|
||||||
|
|
||||||
|
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||||
|
|
||||||
|
const [contextWord, setContextWord] = useState<string | undefined>(undefined);
|
||||||
|
const [contextWordLine, setContextWordLine] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showSolutions && exerciseIndex && exam.shuffle && userSolutions[exerciseIndex].shuffleMaps) {
|
||||||
|
setShuffleMaps(userSolutions[exerciseIndex].shuffleMaps as ShuffleMap[])
|
||||||
|
}
|
||||||
|
}, [showSolutions, exerciseIndex, setShuffleMaps, userSolutions, exam.shuffle])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasExamEnded && exerciseIndex === -1) {
|
||||||
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
|
}
|
||||||
|
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
|
||||||
|
|
||||||
|
const getExercise = () => {
|
||||||
|
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" && !showSolutions) {
|
||||||
|
console.log("Shuffling MC ");
|
||||||
|
const exerciseShuffles = userSolutions[exerciseIndex].shuffleMaps;
|
||||||
|
if (exerciseShuffles && exerciseShuffles.length == 0) {
|
||||||
|
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 MC 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) && !showSolutions) {
|
||||||
|
if (shuffleMaps.length === 0 && !showSolutions) {
|
||||||
|
const newShuffleMaps: ShuffleMap[] = [];
|
||||||
|
console.log("Shuffling Words");
|
||||||
|
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);
|
||||||
|
} else {
|
||||||
|
console.log("Retrieving Words shuffle");
|
||||||
|
exercise.words = exercise.words.map(word => {
|
||||||
|
if ('options' in word) {
|
||||||
|
const shuffleMap = shuffleMaps.find(map => map.id === word.id);
|
||||||
|
if (shuffleMap) {
|
||||||
|
const options = { ...word.options };
|
||||||
|
const shuffledOptions = Object.keys(options).reduce((acc, key) => {
|
||||||
|
const shuffledKey = shuffleMap.map[key as keyof typeof options];
|
||||||
|
acc[shuffledKey as keyof typeof options] = options[key as keyof typeof options];
|
||||||
|
return acc;
|
||||||
|
}, {} as { [key in keyof typeof options]: string });
|
||||||
|
|
||||||
|
return { ...word, options: shuffledOptions };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return word;
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(exercise);
|
||||||
|
return exercise;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (exerciseIndex !== -1) {
|
||||||
|
setCurrentExercise(getExercise());
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [partIndex, exerciseIndex, shuffleMaps, exam.parts[partIndex].context]);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
|
||||||
|
if (exerciseIndex !== -1 && currentExercise && currentExercise.type === "multipleChoice" && currentExercise.questions[storeQuestionIndex].prompt) {
|
||||||
|
const match = currentExercise.questions[storeQuestionIndex].prompt.match(regex);
|
||||||
|
if (match) {
|
||||||
|
const word = match[1];
|
||||||
|
const originalLineNumber = match[2];
|
||||||
|
|
||||||
|
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 {
|
||||||
|
setContextWord(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentExercise, storeQuestionIndex]);
|
||||||
|
|
||||||
|
const nextExercise = (solution?: UserSolution) => {
|
||||||
|
scrollToTop();
|
||||||
|
if (solution) {
|
||||||
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*if (storeQuestionIndex > 0 || currentExercise?.type == "fillBlanks") {
|
||||||
|
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex }]);
|
||||||
|
}*/
|
||||||
|
|
||||||
|
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
|
||||||
|
setExerciseIndex(exerciseIndex + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partIndex + 1 < exam.parts.length && !hasExamEnded && (showQuestionsModal || showSolutions)) {
|
||||||
|
if (!showSolutions && exam.parts[0].intro) {
|
||||||
|
setShowPartDivider(true);
|
||||||
|
setBgColor(levelBgColor);
|
||||||
|
}
|
||||||
|
setPartIndex(partIndex + 1);
|
||||||
|
setExerciseIndex(!!exam.parts[partIndex + 1].context ? -1 : 0);
|
||||||
|
setStoreQuestionIndex(0);
|
||||||
|
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex }]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partIndex + 1 < exam.parts.length && !hasExamEnded && !showQuestionsModal && !showSolutions) {
|
||||||
|
setShowQuestionsModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
solution &&
|
||||||
|
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
|
||||||
|
(x) => x === 0,
|
||||||
|
) &&
|
||||||
|
!showSolutions &&
|
||||||
|
!editing &&
|
||||||
|
!hasExamEnded
|
||||||
|
) {
|
||||||
|
setShowQuestionsModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasExamEnded(false);
|
||||||
|
|
||||||
|
if (solution) {
|
||||||
|
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 {
|
||||||
|
onFinish(userSolutions);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
|
scrollToTop();
|
||||||
|
if (solution) {
|
||||||
|
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level", exam: exam.id }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setExerciseIndex(exerciseIndex - 1);
|
||||||
|
if (exerciseIndex - 1 === -1) {
|
||||||
|
setPartIndex(partIndex - 1);
|
||||||
|
const lastPartExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1;
|
||||||
|
const previousExercise = exam.parts[partIndex - 1].exercises[lastPartExerciseIndex];
|
||||||
|
if (previousExercise.type === "multipleChoice") {
|
||||||
|
setStoreQuestionIndex(previousExercise.questions.length - 1)
|
||||||
|
}
|
||||||
|
const multipleChoiceQuestionsDone = [];
|
||||||
|
for (let i = 0; i < exam.parts.length; i++) {
|
||||||
|
if (i == (partIndex - 1)) break;
|
||||||
|
for (let j = 0; j < exam.parts[i].exercises.length; j++) {
|
||||||
|
const exercise = exam.parts[i].exercises[j];
|
||||||
|
if (exercise.type === "multipleChoice") {
|
||||||
|
multipleChoiceQuestionsDone.push({ id: exercise.id, amount: exercise.questions.length - 1 })
|
||||||
|
}
|
||||||
|
if (exercise.type === "fillBlanks") {
|
||||||
|
multipleChoiceQuestionsDone.push({ id: exercise.id, amount: exercise.words.length - 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMultipleChoicesDone(multipleChoiceQuestionsDone);
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (exerciseIndex === -1) {
|
||||||
|
nextExercise()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [exerciseIndex])
|
||||||
|
|
||||||
|
const calculateExerciseIndex = () => {
|
||||||
|
if (partIndex === 0) {
|
||||||
|
return (
|
||||||
|
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex //+ multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
|
||||||
|
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
|
||||||
|
return (
|
||||||
|
exercisesDone +
|
||||||
|
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
|
||||||
|
storeQuestionIndex
|
||||||
|
+ multipleChoicesDone.reduce((acc, curr) => { return acc + curr.amount }, 0)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderText = () => (
|
||||||
|
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative py-8 px-16")}>
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col w-full gap-2">
|
||||||
|
<h4 className="text-xl font-semibold">
|
||||||
|
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
||||||
|
</h4>
|
||||||
|
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
|
||||||
|
</div>
|
||||||
|
<TextComponent
|
||||||
|
part={exam.parts[partIndex]}
|
||||||
|
contextWord={contextWord}
|
||||||
|
setContextWordLine={setContextWordLine}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const partLabel = () => {
|
||||||
|
if (currentExercise?.type === "fillBlanks" && typeCheckWordsMC(currentExercise.words))
|
||||||
|
return `Part ${partIndex + 1} (Questions ${currentExercise.words[0].id} - ${currentExercise.words[currentExercise.words.length - 1].id})\n\n${currentExercise.prompt}`
|
||||||
|
|
||||||
|
if (currentExercise?.type === "multipleChoice") {
|
||||||
|
return `Part ${partIndex + 1} (Questions ${currentExercise.questions[0].id} - ${currentExercise.questions[currentExercise.questions.length - 1].id})\n\n${currentExercise.prompt}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof exam.parts[partIndex].context === "string") {
|
||||||
|
const nextExercise = exam.parts[partIndex].exercises[0] as MultipleChoiceExercise;
|
||||||
|
return `Part ${partIndex + 1} (Questions ${nextExercise.questions[0].id} - ${nextExercise.questions[nextExercise.questions.length - 1].id})\n\n${nextExercise.prompt}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalKwargs = () => {
|
||||||
|
const allSolutionsCorrectLength = exam.parts[partIndex].exercises.every((exercise) => {
|
||||||
|
const userSolution = userSolutions.find(x => x.exercise === exercise.id);
|
||||||
|
if (exercise.type === "multipleChoice") {
|
||||||
|
return userSolution?.solutions.length === exercise.questions.length;
|
||||||
|
}
|
||||||
|
if (exercise.type === "fillBlanks") {
|
||||||
|
return userSolution?.solutions.length === exercise.words.length;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
blankQuestions: !allSolutionsCorrectLength,
|
||||||
|
finishingWhat: "part",
|
||||||
|
onClose: partIndex !== exam.parts.length - 1 ? (
|
||||||
|
function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } }
|
||||||
|
) : function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); onFinish(userSolutions); } else { setShowQuestionsModal(false) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={clsx("flex flex-col h-full w-full gap-8 items-center", showPartDivider && "justify-center")}>
|
||||||
|
<QuestionsModal isOpen={showQuestionsModal} {...modalKwargs()} />
|
||||||
|
{
|
||||||
|
!(partIndex === 0 && storeQuestionIndex === 0 && showPartDivider) &&
|
||||||
|
<Timer minTimer={exam.minTimer} disableTimer={showSolutions} standalone={true} />
|
||||||
|
}
|
||||||
|
{exam.parts[0].intro && showPartDivider ? <PartDivider part={exam.parts[partIndex]} partIndex={partIndex} onNext={() => { setShowPartDivider(false); setBgColor("bg-white") }} /> : (
|
||||||
|
<>
|
||||||
|
<ModuleTitle
|
||||||
|
partLabel={partLabel()}
|
||||||
|
minTimer={exam.minTimer}
|
||||||
|
exerciseIndex={calculateExerciseIndex()}
|
||||||
|
module="level"
|
||||||
|
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||||
|
disableTimer={showSolutions || editing}
|
||||||
|
showTimer={typeof exam.parts[0].intro === "undefined"}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"mb-20 w-full",
|
||||||
|
partIndex > -1 && exerciseIndex > -1 && !!exam.parts[partIndex].context && "grid grid-cols-2 gap-4",
|
||||||
|
)}>
|
||||||
|
{partIndex > -1 && !!exam.parts[partIndex].context && renderText()}
|
||||||
|
|
||||||
|
{exerciseIndex > -1 &&
|
||||||
|
partIndex > -1 &&
|
||||||
|
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||||
|
!showSolutions &&
|
||||||
|
!editing &&
|
||||||
|
currentExercise &&
|
||||||
|
renderExercise(currentExercise, exam.id, nextExercise, previousExercise)}
|
||||||
|
|
||||||
|
{exerciseIndex > -1 &&
|
||||||
|
partIndex > -1 &&
|
||||||
|
exerciseIndex < exam.parts[partIndex].exercises.length &&
|
||||||
|
(showSolutions || editing) &&
|
||||||
|
currentExercise &&
|
||||||
|
renderSolution(currentExercise, nextExercise, previousExercise)}
|
||||||
|
</div>
|
||||||
|
{/*exerciseIndex === -1 && partIndex > 0 && (
|
||||||
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
||||||
|
setPartIndex(partIndex - 1);
|
||||||
|
}}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={
|
||||||
|
exam && typeof partIndex !== "undefined" && exam.module === "level" &&
|
||||||
|
typeof exam.parts[0].intro === "string" && storeQuestionIndex === 0}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)*/}
|
||||||
|
{exerciseIndex === -1 && partIndex === 0 && (
|
||||||
|
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
||||||
|
Start now
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import {renderSolution} from "@/components/Solutions";
|
|||||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
import AudioPlayer from "@/components/Low/AudioPlayer";
|
import AudioPlayer from "@/components/Low/AudioPlayer";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
import BlankQuestionsModal from "@/components/QuestionsModal";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {countExercises} from "@/utils/moduleUtils";
|
import {countExercises} from "@/utils/moduleUtils";
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import ProgressBar from "@/components/Low/ProgressBar";
|
|||||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
import {Divider} from "primereact/divider";
|
import {Divider} from "primereact/divider";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
|
import BlankQuestionsModal from "@/components/QuestionsModal";
|
||||||
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";
|
||||||
|
|||||||
@@ -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'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'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'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'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're a beginner or a seasoned learner,
|
|
||||||
our exams cater to all levels, providing a comprehensive
|
|
||||||
evaluation of your reading, writing, speaking, and listening
|
|
||||||
skills. Prepare to embark on a journey of self-discovery and
|
|
||||||
language mastery as you navigate through our thoughtfully
|
|
||||||
curated exams. Your success is not just a destination; it's
|
|
||||||
a testament to your dedication and our commitment to empowering
|
|
||||||
you with the English language.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{sessions.length > 0 && (
|
{sessions.length > 0 && (
|
||||||
<section className="flex flex-col gap-3 md:gap-3">
|
<section className="flex flex-col gap-3 md:gap-3">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={reload}
|
onClick={reload}
|
||||||
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"
|
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
|
||||||
>
|
<span className="text-mti-black text-lg font-bold">Unfinished Sessions</span>
|
||||||
<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 flex gap-8 overflow-x-auto pb-2">
|
||||||
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'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'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'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'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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
235
src/exams/pdf/level.test.report.tsx
Normal file
235
src/exams/pdf/level.test.report.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/* eslint-disable jsx-a11y/alt-text */
|
||||||
|
import React from "react";
|
||||||
|
import { Document, Page, View, Text, Image } from "@react-pdf/renderer";
|
||||||
|
import { ModuleScore } from "@/interfaces/module.scores";
|
||||||
|
import { styles } from "./styles";
|
||||||
|
import TestReportFooter from "./test.report.footer";
|
||||||
|
|
||||||
|
import { StyleSheet } from "@react-pdf/renderer";
|
||||||
|
|
||||||
|
const customStyles = StyleSheet.create({
|
||||||
|
testDetails: {
|
||||||
|
display: "flex",
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
testDetailsContainer: {
|
||||||
|
display: "flex",
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
tableRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
},
|
||||||
|
tableCol70: {
|
||||||
|
width: "70%", // First column width (50%)
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#000",
|
||||||
|
// padding: 5,
|
||||||
|
},
|
||||||
|
tableCol25: {
|
||||||
|
width: "16.67%", // Remaining four columns each get 1/6 of the total width (50% / 3 = 16.67%)
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#000",
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
tableCol20: {
|
||||||
|
width: "20%", // Width for each of the 5 sub-columns (50% / 5 = 20%)
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#000",
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
tableCol10: {
|
||||||
|
width: "10%", // Width for each of the 5 sub-columns (50% / 5 = 20%)
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#000",
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
tableCellHeader: {
|
||||||
|
fontSize: 12,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
tableCellHeaderColor: {
|
||||||
|
backgroundColor: "#d3d3d3",
|
||||||
|
},
|
||||||
|
tableCell: {
|
||||||
|
fontSize: 10,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
date: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
id: string;
|
||||||
|
gender?: string;
|
||||||
|
passportId: string;
|
||||||
|
corporateName: string;
|
||||||
|
downloadDate: string;
|
||||||
|
userId: string;
|
||||||
|
uniqueExercises: { name: string; result: string }[];
|
||||||
|
timeSpent: string;
|
||||||
|
score: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LevelTestReport = ({
|
||||||
|
date,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
id,
|
||||||
|
gender,
|
||||||
|
passportId,
|
||||||
|
corporateName,
|
||||||
|
downloadDate,
|
||||||
|
userId,
|
||||||
|
uniqueExercises,
|
||||||
|
timeSpent,
|
||||||
|
score,
|
||||||
|
}: Props) => {
|
||||||
|
const defaultTextStyle = [styles.textFont, { fontSize: 8 }];
|
||||||
|
return (
|
||||||
|
<Document>
|
||||||
|
<Page style={styles.body} orientation="landscape">
|
||||||
|
<Text style={[styles.textFont, styles.textBold, { fontSize: 11 }]}>
|
||||||
|
Corporate Name: {corporateName}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.textMargin}>
|
||||||
|
<Text style={defaultTextStyle}>
|
||||||
|
Report Download date: {downloadDate}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.textFont, styles.textBold, { fontSize: 11 }]}>
|
||||||
|
Test Information: {id}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.textMargin}>
|
||||||
|
<Text style={defaultTextStyle}>Date of Test: {date}</Text>
|
||||||
|
<Text style={defaultTextStyle}>Candidates name: {name}</Text>
|
||||||
|
<Text style={defaultTextStyle}>Email: {email}</Text>
|
||||||
|
<Text style={defaultTextStyle}>National ID: {passportId}</Text>
|
||||||
|
|
||||||
|
<Text style={defaultTextStyle}>Gender: {gender}</Text>
|
||||||
|
<Text style={defaultTextStyle}>Candidate ID: {userId}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={customStyles.table}>
|
||||||
|
{/* Header Row */}
|
||||||
|
<View style={customStyles.tableRow}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
customStyles.tableCol70,
|
||||||
|
customStyles.tableCellHeaderColor,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[customStyles.tableCellHeader, { padding: 5 }]}>
|
||||||
|
Test sections
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
customStyles.tableCol20,
|
||||||
|
customStyles.tableCellHeaderColor,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={customStyles.tableCellHeader}>Time spent</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
customStyles.tableCol10,
|
||||||
|
customStyles.tableCellHeaderColor,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={customStyles.tableCellHeader}>Score</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={customStyles.tableRow}>
|
||||||
|
<View style={customStyles.tableCol70}>
|
||||||
|
<View style={customStyles.tableRow}>
|
||||||
|
{uniqueExercises.map((exercise, index) => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
customStyles.tableCol20,
|
||||||
|
index !== uniqueExercises.length - 1
|
||||||
|
? { borderWidth: 0, borderRightWidth: 1 }
|
||||||
|
: { borderWidth: 0 },
|
||||||
|
]}
|
||||||
|
key={index}
|
||||||
|
>
|
||||||
|
<Text style={customStyles.tableCell}>Part {index + 1}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={customStyles.tableCol20}>
|
||||||
|
<Text style={customStyles.tableCell}></Text>
|
||||||
|
</View>
|
||||||
|
<View style={customStyles.tableCol10}>
|
||||||
|
<Text style={customStyles.tableCell}></Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={customStyles.tableRow}>
|
||||||
|
<View style={customStyles.tableCol70}>
|
||||||
|
<View style={customStyles.tableRow}>
|
||||||
|
{uniqueExercises.map((exercise, index) => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
customStyles.tableCol20,
|
||||||
|
index !== uniqueExercises.length - 1
|
||||||
|
? { borderWidth: 0, borderRightWidth: 1 }
|
||||||
|
: { borderWidth: 0 },
|
||||||
|
]}
|
||||||
|
key={index}
|
||||||
|
>
|
||||||
|
<Text style={customStyles.tableCell}>{exercise.name}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={customStyles.tableCol20}>
|
||||||
|
<Text style={customStyles.tableCell}></Text>
|
||||||
|
</View>
|
||||||
|
<View style={customStyles.tableCol10}>
|
||||||
|
<Text style={customStyles.tableCell}></Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={customStyles.tableRow}>
|
||||||
|
<View style={customStyles.tableCol70}>
|
||||||
|
<View style={customStyles.tableRow}>
|
||||||
|
{uniqueExercises.map((exercise, index) => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
customStyles.tableCol20,
|
||||||
|
index !== uniqueExercises.length - 1
|
||||||
|
? { borderWidth: 0, borderRightWidth: 1 }
|
||||||
|
: { borderWidth: 0 },
|
||||||
|
]}
|
||||||
|
key={index}
|
||||||
|
>
|
||||||
|
<Text style={customStyles.tableCell}>
|
||||||
|
{exercise.result}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={customStyles.tableCol20}>
|
||||||
|
<Text style={customStyles.tableCell}>{timeSpent}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={customStyles.tableCol10}>
|
||||||
|
<Text style={customStyles.tableCell}>{score}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TestReportFooter userId={userId} />
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LevelTestReport;
|
||||||
34
src/hooks/useAssignmentCorporates.tsx
Normal file
34
src/hooks/useAssignmentCorporates.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Assignment } from "@/interfaces/results";
|
||||||
|
import axios from "axios";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function useAssignmentsCorporates({
|
||||||
|
corporates,
|
||||||
|
}: {
|
||||||
|
corporates: string[];
|
||||||
|
}) {
|
||||||
|
const [assignments, setAssignments] = useState<Assignment[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
|
const getData = () => {
|
||||||
|
if (corporates.length === 0) {
|
||||||
|
setAssignments([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
axios
|
||||||
|
.get<Assignment[]>(
|
||||||
|
`/api/assignments/corporate?ids=${corporates.join(",")}`
|
||||||
|
)
|
||||||
|
.then(async (response) => {
|
||||||
|
setAssignments(response.data);
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(getData, [corporates]);
|
||||||
|
|
||||||
|
return { assignments, isLoading, isError, reload: getData };
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import {Assignment} from "@/interfaces/results";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
|
||||||
export default function useAssignments({assigner, assignees}: {assigner?: string; assignees?: string}) {
|
export default function useAssignments({assigner, assignees, corporate}: {assigner?: string; assignees?: string; corporate?: string}) {
|
||||||
const [assignments, setAssignments] = useState<Assignment[]>([]);
|
const [assignments, setAssignments] = useState<Assignment[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isError, setIsError] = useState(false);
|
const [isError, setIsError] = useState(false);
|
||||||
@@ -10,12 +10,13 @@ export default function useAssignments({assigner, assignees}: {assigner?: string
|
|||||||
const getData = () => {
|
const getData = () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.get<Assignment[]>("/api/assignments")
|
.get<Assignment[]>(!corporate ? "/api/assignments" : `/api/assignments/corporate/${corporate}`)
|
||||||
.then((response) => {
|
.then(async (response) => {
|
||||||
if (assigner) {
|
if (assigner) {
|
||||||
setAssignments(response.data.filter((a) => a.assigner === assigner));
|
setAssignments(response.data.filter((a) => a.assigner === assigner));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (assignees) {
|
if (assignees) {
|
||||||
setAssignments(response.data.filter((a) => a.assignees.filter((x) => assignees.includes(x)).length > 0));
|
setAssignments(response.data.filter((a) => a.assignees.filter((x) => assignees.includes(x)).length > 0));
|
||||||
return;
|
return;
|
||||||
@@ -26,7 +27,7 @@ export default function useAssignments({assigner, assignees}: {assigner?: string
|
|||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(getData, [assignees, assigner]);
|
useEffect(getData, [assignees, assigner, corporate]);
|
||||||
|
|
||||||
return {assignments, isLoading, isError, reload: getData};
|
return {assignments, isLoading, isError, reload: getData};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export function useListSearch<T>(fields: string[][], rows: T[]) {
|
|||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
return value.toLowerCase().includes(searchText);
|
return value.toLowerCase().includes(searchText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return (value as Number).toString().includes(searchText);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [fields, rows, text]);
|
}, [fields, rows, text]);
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { BsFilePdf } from "react-icons/bs";
|
import { BsFilePdf, BsFileExcel} from "react-icons/bs";
|
||||||
|
|
||||||
type DownloadingPdf = {
|
type DownloadingPdf = {
|
||||||
[key: string]: boolean;
|
[key: string]: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PdfEndpoint = "stats" | "assignments";
|
type PdfEndpoint = "stats" | "assignments";
|
||||||
|
type FileType = "pdf" | "excel";
|
||||||
|
|
||||||
export const usePDFDownload = (endpoint: PdfEndpoint) => {
|
export const usePDFDownload = (endpoint: PdfEndpoint, file: FileType = 'pdf') => {
|
||||||
const [downloadingPdf, setDownloadingPdf] = React.useState<DownloadingPdf>(
|
const [downloadingPdf, setDownloadingPdf] = React.useState<DownloadingPdf>(
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
@@ -17,7 +18,7 @@ export const usePDFDownload = (endpoint: PdfEndpoint) => {
|
|||||||
const triggerDownload = async (id: string) => {
|
const triggerDownload = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
setDownloadingPdf((prev) => ({ ...prev, [id]: true }));
|
setDownloadingPdf((prev) => ({ ...prev, [id]: true }));
|
||||||
const res = await axios.post(`/api/${endpoint}/${id}/export`);
|
const res = await axios.post(`/api/${endpoint}/${id}/export/${file}`);
|
||||||
toast.success("Report ready!");
|
toast.success("Report ready!");
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = res.data;
|
link.href = res.data;
|
||||||
@@ -45,8 +46,11 @@ export const usePDFDownload = (endpoint: PdfEndpoint) => {
|
|||||||
<span className={`${loadingClasses} loading loading-infinity w-6`} />
|
<span className={`${loadingClasses} loading loading-infinity w-6`} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Icon = file === "excel" ? BsFileExcel : BsFilePdf;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BsFilePdf
|
<Icon
|
||||||
className={`${downloadClasses} text-2xl cursor-pointer`}
|
className={`${downloadClasses} text-2xl cursor-pointer`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
28
src/hooks/usePermissions.tsx
Normal file
28
src/hooks/usePermissions.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import {Exam} from "@/interfaces/exam";
|
||||||
|
import {Permission, PermissionType} from "@/interfaces/permissions";
|
||||||
|
import {ExamState} from "@/stores/examStore";
|
||||||
|
import axios from "axios";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
|
||||||
|
export default function usePermissions(user: string) {
|
||||||
|
const [permissions, setPermissions] = useState<PermissionType[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isError, setIsError] = useState(false);
|
||||||
|
|
||||||
|
const getData = () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
axios
|
||||||
|
.get<Permission[]>(`/api/permissions`)
|
||||||
|
.then((response) => {
|
||||||
|
const permissionTypes = response.data
|
||||||
|
.filter((x) => !x.users.includes(user))
|
||||||
|
.reduce((acc, curr) => [...acc, curr.type], [] as PermissionType[]);
|
||||||
|
setPermissions(permissionTypes);
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(getData, [user]);
|
||||||
|
|
||||||
|
return {permissions, isLoading, isError, reload: getData};
|
||||||
|
}
|
||||||
@@ -1,19 +1,25 @@
|
|||||||
import {Module} from ".";
|
import { Module } from ".";
|
||||||
|
|
||||||
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
||||||
export type Variant = "full" | "partial";
|
export type Variant = "full" | "partial";
|
||||||
export type InstructorGender = "male" | "female" | "varied";
|
export type InstructorGender = "male" | "female" | "varied";
|
||||||
export type Difficulty = "easy" | "medium" | "hard";
|
export type Difficulty = "easy" | "medium" | "hard";
|
||||||
|
|
||||||
export interface ReadingExam {
|
interface ExamBase {
|
||||||
parts: ReadingPart[];
|
|
||||||
id: string;
|
id: string;
|
||||||
module: "reading";
|
module: Module;
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
type: "academic" | "general";
|
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
difficulty?: Difficulty;
|
difficulty?: Difficulty;
|
||||||
|
shuffle?: boolean;
|
||||||
|
createdBy?: string; // option as it has been added later
|
||||||
|
createdAt?: string; // option as it has been added later
|
||||||
|
}
|
||||||
|
export interface ReadingExam extends ExamBase {
|
||||||
|
module: "reading";
|
||||||
|
parts: ReadingPart[];
|
||||||
|
type: "academic" | "general";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReadingPart {
|
export interface ReadingPart {
|
||||||
@@ -24,29 +30,20 @@ export interface ReadingPart {
|
|||||||
exercises: Exercise[];
|
exercises: Exercise[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LevelExam {
|
export interface LevelExam extends ExamBase {
|
||||||
module: "level";
|
module: "level";
|
||||||
id: string;
|
|
||||||
parts: LevelPart[];
|
parts: LevelPart[];
|
||||||
minTimer: number;
|
|
||||||
isDiagnostic: boolean;
|
|
||||||
variant?: Variant;
|
|
||||||
difficulty?: Difficulty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LevelPart {
|
export interface LevelPart {
|
||||||
context?: string;
|
context?: string;
|
||||||
|
intro?: string;
|
||||||
exercises: Exercise[];
|
exercises: Exercise[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListeningExam {
|
export interface ListeningExam extends ExamBase {
|
||||||
parts: ListeningPart[];
|
parts: ListeningPart[];
|
||||||
id: string;
|
|
||||||
module: "listening";
|
module: "listening";
|
||||||
minTimer: number;
|
|
||||||
isDiagnostic: boolean;
|
|
||||||
variant?: Variant;
|
|
||||||
difficulty?: Difficulty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListeningPart {
|
export interface ListeningPart {
|
||||||
@@ -70,16 +67,12 @@ export interface UserSolution {
|
|||||||
};
|
};
|
||||||
exercise: string;
|
exercise: string;
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
|
shuffleMaps?: ShuffleMap[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WritingExam {
|
export interface WritingExam extends ExamBase {
|
||||||
module: "writing";
|
module: "writing";
|
||||||
id: string;
|
|
||||||
exercises: WritingExercise[];
|
exercises: WritingExercise[];
|
||||||
minTimer: number;
|
|
||||||
isDiagnostic: boolean;
|
|
||||||
variant?: Variant;
|
|
||||||
difficulty?: Difficulty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WordCounter {
|
interface WordCounter {
|
||||||
@@ -87,15 +80,10 @@ interface WordCounter {
|
|||||||
limit: number;
|
limit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpeakingExam {
|
export interface SpeakingExam extends ExamBase {
|
||||||
id: string;
|
|
||||||
module: "speaking";
|
module: "speaking";
|
||||||
exercises: (SpeakingExercise | InteractiveSpeakingExercise)[];
|
exercises: (SpeakingExercise | InteractiveSpeakingExercise)[];
|
||||||
minTimer: number;
|
|
||||||
isDiagnostic: boolean;
|
|
||||||
variant?: Variant;
|
|
||||||
instructorGender: InstructorGender;
|
instructorGender: InstructorGender;
|
||||||
difficulty?: Difficulty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Exercise =
|
export type Exercise =
|
||||||
@@ -111,21 +99,24 @@ 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 }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InteractiveSpeakingEvaluation extends Evaluation {
|
|
||||||
perfect_answer_1?: {answer: string};
|
type InteractivePerfectAnswerKey = `perfect_answer_${number}`;
|
||||||
transcript_1?: string;
|
type InteractiveTranscriptKey = `transcript_${number}`;
|
||||||
fixed_text_1?: string;
|
type InteractiveFixedTextKey = `fixed_text_${number}`;
|
||||||
perfect_answer_2?: {answer: string};
|
|
||||||
transcript_2?: string;
|
type InteractivePerfectAnswerType = { [key in InteractivePerfectAnswerKey]: { answer: string } };
|
||||||
fixed_text_2?: string;
|
type InteractiveTranscriptType = { [key in InteractiveTranscriptKey]?: string };
|
||||||
perfect_answer_3?: {answer: string};
|
type InteractiveFixedTextType = { [key in InteractiveFixedTextKey]?: string };
|
||||||
transcript_3?: string;
|
|
||||||
fixed_text_3?: string;
|
interface InteractiveSpeakingEvaluation extends Evaluation,
|
||||||
}
|
InteractivePerfectAnswerType,
|
||||||
|
InteractiveTranscriptType,
|
||||||
|
InteractiveFixedTextType { }
|
||||||
|
|
||||||
|
|
||||||
interface SpeakingEvaluation extends CommonEvaluation {
|
interface SpeakingEvaluation extends CommonEvaluation {
|
||||||
perfect_answer_1?: string;
|
perfect_answer_1?: string;
|
||||||
@@ -198,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;
|
||||||
@@ -210,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"
|
||||||
@@ -225,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 {
|
||||||
@@ -232,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 {
|
||||||
@@ -261,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[];
|
||||||
@@ -284,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 {
|
||||||
@@ -297,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,89 @@
|
|||||||
export const markets = ["au", "br", "de"] as const;
|
export interface PermissionTopic {
|
||||||
|
topic: string;
|
||||||
|
list: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export const permissions = [
|
export const permissions = [
|
||||||
// generate codes are basicly invites
|
{
|
||||||
"createCodeStudent",
|
topic: "Manage Corporate",
|
||||||
"createCodeTeacher",
|
list: [
|
||||||
"createCodeCorporate",
|
"viewCorporate",
|
||||||
"createCodeCountryManager",
|
"editCorporate",
|
||||||
"createCodeAdmin",
|
"deleteCorporate",
|
||||||
// exams
|
"createCodeCorporate",
|
||||||
"createReadingExam",
|
],
|
||||||
"createListeningExam",
|
},
|
||||||
"createWritingExam",
|
{
|
||||||
"createSpeakingExam",
|
topic: "Manage Admin",
|
||||||
"createLevelExam",
|
list: ["viewAdmin", "editAdmin", "deleteAdmin", "createCodeAdmin"],
|
||||||
// view pages
|
},
|
||||||
"viewExams",
|
{
|
||||||
"viewExercises",
|
topic: "Manage Student",
|
||||||
"viewRecords",
|
list: ["viewStudent", "editStudent", "deleteStudent", "createCodeStudent"],
|
||||||
"viewStats",
|
},
|
||||||
"viewTickets",
|
{
|
||||||
"viewPaymentRecords",
|
topic: "Manage Teacher",
|
||||||
// view data
|
list: ["viewTeacher", "editTeacher", "deleteTeacher", "createCodeTeacher"],
|
||||||
"viewStudent",
|
},
|
||||||
"viewTeacher",
|
{
|
||||||
"viewCorporate",
|
topic: "Manage Country Manager",
|
||||||
"viewCountryManager",
|
list: [
|
||||||
"viewAdmin",
|
"viewCountryManager",
|
||||||
// edit data
|
"editCountryManager",
|
||||||
"editStudent",
|
"deleteCountryManager",
|
||||||
"editTeacher",
|
"createCodeCountryManager",
|
||||||
"editCorporate",
|
],
|
||||||
"editCountryManager",
|
},
|
||||||
"editAdmin",
|
{
|
||||||
// delete data
|
topic: "Manage Exams",
|
||||||
"deleteStudent",
|
list: [
|
||||||
"deleteTeacher",
|
"createReadingExam",
|
||||||
"deleteCorporate",
|
"createListeningExam",
|
||||||
"deleteCountryManager",
|
"createWritingExam",
|
||||||
"deleteAdmin",
|
"createSpeakingExam",
|
||||||
|
"createLevelExam",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: "View Pages",
|
||||||
|
list: [
|
||||||
|
"viewExams",
|
||||||
|
"viewExercises",
|
||||||
|
"viewRecords",
|
||||||
|
"viewStats",
|
||||||
|
"viewTickets",
|
||||||
|
"viewPaymentRecords",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: "Manage Group",
|
||||||
|
list: ["viewGroup", "editGroup", "deleteGroup", "createGroup"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: "Manage Codes",
|
||||||
|
list: ["viewCodes", "deleteCodes", "createCodes"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: "Others",
|
||||||
|
list: ["all"],
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type PermissionType = (typeof permissions)[keyof typeof permissions];
|
const permissionsList = [
|
||||||
|
...new Set(
|
||||||
|
permissions.reduce(
|
||||||
|
(accm: string[], permission) => [...accm, ...permission.list],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
export type PermissionType =
|
||||||
|
(typeof permissionsList)[keyof typeof permissionsList];
|
||||||
|
|
||||||
export interface Permission {
|
export interface Permission {
|
||||||
id: string;
|
id: string;
|
||||||
type: PermissionType;
|
type: PermissionType;
|
||||||
|
topic: string;
|
||||||
users: string[];
|
users: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
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 =
|
||||||
@@ -27,7 +27,8 @@ export interface BasicUser {
|
|||||||
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 {
|
||||||
@@ -148,6 +149,11 @@ export interface Stat {
|
|||||||
missing: number;
|
missing: number;
|
||||||
};
|
};
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
|
shuffleMaps?: ShuffleMap[];
|
||||||
|
pdf?: {
|
||||||
|
path: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Group {
|
export interface Group {
|
||||||
|
|||||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal 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))
|
||||||
|
}
|
||||||
@@ -1,366 +1,280 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import { Type, User } from "@/interfaces/user";
|
import {Type, User} from "@/interfaces/user";
|
||||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { capitalize, uniqBy } from "lodash";
|
import {capitalize, uniqBy} from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { useEffect, useState } from "react";
|
import {useEffect, useState} from "react";
|
||||||
import ReactDatePicker from "react-datepicker";
|
import ReactDatePicker from "react-datepicker";
|
||||||
import { toast } from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
import { useFilePicker } from "use-file-picker";
|
import {useFilePicker} from "use-file-picker";
|
||||||
import readXlsxFile from "read-excel-file";
|
import readXlsxFile from "read-excel-file";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import { BsFileEarmarkEaselFill, BsQuestionCircleFill } from "react-icons/bs";
|
import {BsFileEarmarkEaselFill, BsQuestionCircleFill} from "react-icons/bs";
|
||||||
import { checkAccess } from "@/utils/permissions";
|
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
||||||
import { PermissionType } from "@/interfaces/permissions";
|
import {PermissionType} from "@/interfaces/permissions";
|
||||||
const EMAIL_REGEX = new RegExp(
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/
|
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
||||||
);
|
|
||||||
|
|
||||||
const USER_TYPE_PERMISSIONS: {
|
const USER_TYPE_PERMISSIONS: {
|
||||||
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
[key in Type]: {perm: PermissionType | undefined; list: Type[]};
|
||||||
} = {
|
} = {
|
||||||
student: {
|
student: {
|
||||||
perm: "createCodeStudent",
|
perm: "createCodeStudent",
|
||||||
list: [],
|
list: [],
|
||||||
},
|
},
|
||||||
teacher: {
|
teacher: {
|
||||||
perm: "createCodeTeacher",
|
perm: "createCodeTeacher",
|
||||||
list: [],
|
list: [],
|
||||||
},
|
},
|
||||||
agent: {
|
agent: {
|
||||||
perm: "createCodeCountryManager",
|
perm: "createCodeCountryManager",
|
||||||
list: [],
|
list: [],
|
||||||
},
|
},
|
||||||
corporate: {
|
corporate: {
|
||||||
perm: "createCodeCorporate",
|
perm: "createCodeCorporate",
|
||||||
list: ["student", "teacher"],
|
list: ["student", "teacher"],
|
||||||
},
|
},
|
||||||
mastercorporate: {
|
mastercorporate: {
|
||||||
perm: undefined,
|
perm: undefined,
|
||||||
list: ["student", "teacher", "corporate"],
|
list: ["student", "teacher", "corporate"],
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
perm: "createCodeAdmin",
|
perm: "createCodeAdmin",
|
||||||
list: [
|
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
|
||||||
"student",
|
},
|
||||||
"teacher",
|
developer: {
|
||||||
"agent",
|
perm: undefined,
|
||||||
"corporate",
|
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
|
||||||
"admin",
|
},
|
||||||
"mastercorporate",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
developer: {
|
|
||||||
perm: undefined,
|
|
||||||
list: [
|
|
||||||
"student",
|
|
||||||
"teacher",
|
|
||||||
"agent",
|
|
||||||
"corporate",
|
|
||||||
"admin",
|
|
||||||
"developer",
|
|
||||||
"mastercorporate",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function BatchCodeGenerator({ user }: { user: User }) {
|
export default function BatchCodeGenerator({user}: {user: User}) {
|
||||||
const [infos, setInfos] = useState<
|
const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]);
|
||||||
{ email: string; name: string; passport_id: string }[]
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
>([]);
|
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
);
|
||||||
user?.subscriptionExpirationDate
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
? moment(user.subscriptionExpirationDate).toDate()
|
const [type, setType] = useState<Type>("student");
|
||||||
: null
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
);
|
|
||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
|
||||||
const [type, setType] = useState<Type>("student");
|
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
|
||||||
|
|
||||||
const { users } = useUsers();
|
const {users} = useUsers();
|
||||||
|
const {permissions} = usePermissions(user?.id || "");
|
||||||
|
|
||||||
const { openFilePicker, filesContent, clear } = useFilePicker({
|
const {openFilePicker, filesContent, clear} = useFilePicker({
|
||||||
accept: ".xlsx",
|
accept: ".xlsx",
|
||||||
multiple: false,
|
multiple: false,
|
||||||
readAs: "ArrayBuffer",
|
readAs: "ArrayBuffer",
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||||
}, [isExpiryDateEnabled]);
|
}, [isExpiryDateEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filesContent.length > 0) {
|
if (filesContent.length > 0) {
|
||||||
const file = filesContent[0];
|
const file = filesContent[0];
|
||||||
readXlsxFile(file.content).then((rows) => {
|
readXlsxFile(file.content).then((rows) => {
|
||||||
try {
|
try {
|
||||||
const information = uniqBy(
|
const information = uniqBy(
|
||||||
rows
|
rows
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const [
|
const [firstName, lastName, country, passport_id, email, ...phone] = row as string[];
|
||||||
firstName,
|
return EMAIL_REGEX.test(email.toString().trim())
|
||||||
lastName,
|
? {
|
||||||
country,
|
email: email.toString().trim().toLowerCase(),
|
||||||
passport_id,
|
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
||||||
email,
|
passport_id: passport_id?.toString().trim() || undefined,
|
||||||
...phone
|
}
|
||||||
] = row as string[];
|
: undefined;
|
||||||
return EMAIL_REGEX.test(email.toString().trim())
|
})
|
||||||
? {
|
.filter((x) => !!x) as typeof infos,
|
||||||
email: email.toString().trim().toLowerCase(),
|
(x) => x.email,
|
||||||
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
);
|
||||||
passport_id: passport_id?.toString().trim() || undefined,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
})
|
|
||||||
.filter((x) => !!x) as typeof infos,
|
|
||||||
(x) => x.email
|
|
||||||
);
|
|
||||||
|
|
||||||
if (information.length === 0) {
|
if (information.length === 0) {
|
||||||
toast.error(
|
toast.error(
|
||||||
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!"
|
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
|
||||||
);
|
);
|
||||||
return clear();
|
return clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
setInfos(information);
|
setInfos(information);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(
|
toast.error(
|
||||||
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!"
|
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
|
||||||
);
|
);
|
||||||
return clear();
|
return clear();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filesContent]);
|
}, [filesContent]);
|
||||||
|
|
||||||
const generateAndInvite = async () => {
|
const generateAndInvite = async () => {
|
||||||
const newUsers = infos.filter(
|
const newUsers = infos.filter((x) => !users.map((u) => u.email).includes(x.email));
|
||||||
(x) => !users.map((u) => u.email).includes(x.email)
|
const existingUsers = infos
|
||||||
);
|
.filter((x) => users.map((u) => u.email).includes(x.email))
|
||||||
const existingUsers = infos
|
.map((i) => users.find((u) => u.email === i.email))
|
||||||
.filter((x) => users.map((u) => u.email).includes(x.email))
|
.filter((x) => !!x && x.type === "student") as User[];
|
||||||
.map((i) => users.find((u) => u.email === i.email))
|
|
||||||
.filter((x) => !!x && x.type === "student") as User[];
|
|
||||||
|
|
||||||
const newUsersSentence =
|
const newUsersSentence = newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
|
||||||
newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
|
const existingUsersSentence = existingUsers.length > 0 ? `invite ${existingUsers.length} registered student(s)` : undefined;
|
||||||
const existingUsersSentence =
|
if (
|
||||||
existingUsers.length > 0
|
!confirm(
|
||||||
? `invite ${existingUsers.length} registered student(s)`
|
`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`,
|
||||||
: undefined;
|
)
|
||||||
if (
|
)
|
||||||
!confirm(
|
return;
|
||||||
`You are about to ${[newUsersSentence, existingUsersSentence]
|
|
||||||
.filter((x) => !!x)
|
|
||||||
.join(" and ")}, are you sure you want to continue?`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
Promise.all(
|
Promise.all(existingUsers.map(async (u) => await axios.post(`/api/invites`, {to: u.id, from: user.id})))
|
||||||
existingUsers.map(
|
.then(() => toast.success(`Successfully invited ${existingUsers.length} registered student(s)!`))
|
||||||
async (u) =>
|
.finally(() => {
|
||||||
await axios.post(`/api/invites`, { to: u.id, from: user.id })
|
if (newUsers.length === 0) setIsLoading(false);
|
||||||
)
|
});
|
||||||
)
|
|
||||||
.then(() =>
|
|
||||||
toast.success(
|
|
||||||
`Successfully invited ${existingUsers.length} registered student(s)!`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.finally(() => {
|
|
||||||
if (newUsers.length === 0) setIsLoading(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (newUsers.length > 0) generateCode(type, newUsers);
|
if (newUsers.length > 0) generateCode(type, newUsers);
|
||||||
setInfos([]);
|
setInfos([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateCode = (type: Type, informations: typeof infos) => {
|
const generateCode = (type: Type, informations: typeof infos) => {
|
||||||
const uid = new ShortUniqueId();
|
const uid = new ShortUniqueId();
|
||||||
const codes = informations.map(() => uid.randomUUID(6));
|
const codes = informations.map(() => uid.randomUUID(6));
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", {
|
.post<{ok: boolean; valid?: number; reason?: string}>("/api/code", {
|
||||||
type,
|
type,
|
||||||
codes,
|
codes,
|
||||||
infos: informations,
|
infos: informations,
|
||||||
expiryDate,
|
expiryDate,
|
||||||
})
|
})
|
||||||
.then(({ data, status }) => {
|
.then(({data, status}) => {
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
toast.success(
|
toast.success(
|
||||||
`Successfully generated${
|
`Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize(
|
||||||
data.valid ? ` ${data.valid}/${informations.length}` : ""
|
type,
|
||||||
} ${capitalize(type)} codes and they have been notified by e-mail!`,
|
)} codes and they have been notified by e-mail!`,
|
||||||
{ toastId: "success" }
|
{toastId: "success"},
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 403) {
|
if (status === 403) {
|
||||||
toast.error(data.reason, { toastId: "forbidden" });
|
toast.error(data.reason, {toastId: "forbidden"});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(({ response: { status, data } }) => {
|
.catch(({response: {status, data}}) => {
|
||||||
if (status === 403) {
|
if (status === 403) {
|
||||||
toast.error(data.reason, { toastId: "forbidden" });
|
toast.error(data.reason, {toastId: "forbidden"});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error(`Something went wrong, please try again later!`, {
|
toast.error(`Something went wrong, please try again later!`, {
|
||||||
toastId: "error",
|
toastId: "error",
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return clear();
|
return clear();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal
|
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)} title="Excel File Format">
|
||||||
isOpen={showHelp}
|
<div className="mt-4 flex flex-col gap-2">
|
||||||
onClose={() => setShowHelp(false)}
|
<span>Please upload an Excel file with the following format:</span>
|
||||||
title="Excel File Format"
|
<table className="w-full">
|
||||||
>
|
<thead>
|
||||||
<div className="mt-4 flex flex-col gap-2">
|
<tr>
|
||||||
<span>Please upload an Excel file with the following format:</span>
|
<th className="border border-neutral-200 px-2 py-1">First Name</th>
|
||||||
<table className="w-full">
|
<th className="border border-neutral-200 px-2 py-1">Last Name</th>
|
||||||
<thead>
|
<th className="border border-neutral-200 px-2 py-1">Country</th>
|
||||||
<tr>
|
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
|
||||||
<th className="border border-neutral-200 px-2 py-1">
|
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
|
||||||
First Name
|
<th className="border border-neutral-200 px-2 py-1">Phone Number</th>
|
||||||
</th>
|
</tr>
|
||||||
<th className="border border-neutral-200 px-2 py-1">
|
</thead>
|
||||||
Last Name
|
</table>
|
||||||
</th>
|
<span className="mt-4">
|
||||||
<th className="border border-neutral-200 px-2 py-1">Country</th>
|
<b>Notes:</b>
|
||||||
<th className="border border-neutral-200 px-2 py-1">
|
<ul>
|
||||||
Passport/National ID
|
<li>- All incorrect e-mails will be ignored;</li>
|
||||||
</th>
|
<li>- All already registered e-mails will be ignored;</li>
|
||||||
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
|
<li>- You may have a header row with the format above, however, it is not necessary;</li>
|
||||||
<th className="border border-neutral-200 px-2 py-1">
|
<li>- All of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.</li>
|
||||||
Phone Number
|
</ul>
|
||||||
</th>
|
</span>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
</Modal>
|
||||||
</table>
|
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
|
||||||
<span className="mt-4">
|
<div className="flex items-end justify-between">
|
||||||
<b>Notes:</b>
|
<label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label>
|
||||||
<ul>
|
<div className="tooltip cursor-pointer" data-tip="Excel File Format" onClick={() => setShowHelp(true)}>
|
||||||
<li>- All incorrect e-mails will be ignored;</li>
|
<BsQuestionCircleFill />
|
||||||
<li>- All already registered e-mails will be ignored;</li>
|
</div>
|
||||||
<li>
|
</div>
|
||||||
- You may have a header row with the format above, however, it
|
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
|
||||||
is not necessary;
|
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||||
</li>
|
</Button>
|
||||||
<li>
|
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
||||||
- All of the e-mails in the file will receive an e-mail to join
|
<>
|
||||||
EnCoach with the role selected below.
|
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||||
</li>
|
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
||||||
</ul>
|
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
|
||||||
</span>
|
Enabled
|
||||||
</div>
|
</Checkbox>
|
||||||
</Modal>
|
</div>
|
||||||
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
|
{isExpiryDateEnabled && (
|
||||||
<div className="flex items-end justify-between">
|
<ReactDatePicker
|
||||||
<label className="text-mti-gray-dim text-base font-normal">
|
className={clsx(
|
||||||
Choose an Excel file
|
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||||
</label>
|
"hover:border-mti-purple tooltip",
|
||||||
<div
|
"transition duration-300 ease-in-out",
|
||||||
className="tooltip cursor-pointer"
|
)}
|
||||||
data-tip="Excel File Format"
|
filterDate={(date) =>
|
||||||
onClick={() => setShowHelp(true)}
|
moment(date).isAfter(new Date()) &&
|
||||||
>
|
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
|
||||||
<BsQuestionCircleFill />
|
}
|
||||||
</div>
|
dateFormat="dd/MM/yyyy"
|
||||||
</div>
|
selected={expiryDate}
|
||||||
<Button
|
onChange={(date) => setExpiryDate(date)}
|
||||||
onClick={openFilePicker}
|
/>
|
||||||
isLoading={isLoading}
|
)}
|
||||||
disabled={isLoading}
|
</>
|
||||||
>
|
)}
|
||||||
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
<label className="text-mti-gray-dim text-base font-normal">Select the type of user they should be</label>
|
||||||
</Button>
|
{user && (
|
||||||
{user &&
|
<select
|
||||||
checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
defaultValue="student"
|
||||||
<>
|
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none">
|
||||||
<label className="text-mti-gray-dim text-base font-normal">
|
{Object.keys(USER_TYPE_LABELS)
|
||||||
Expiry Date
|
.filter((x) => {
|
||||||
</label>
|
const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
|
||||||
<Checkbox
|
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
||||||
isChecked={isExpiryDateEnabled}
|
})
|
||||||
onChange={setIsExpiryDateEnabled}
|
.map((type) => (
|
||||||
disabled={!!user.subscriptionExpirationDate}
|
<option key={type} value={type}>
|
||||||
>
|
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||||
Enabled
|
</option>
|
||||||
</Checkbox>
|
))}
|
||||||
</div>
|
</select>
|
||||||
{isExpiryDateEnabled && (
|
)}
|
||||||
<ReactDatePicker
|
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && (
|
||||||
className={clsx(
|
<Button onClick={generateAndInvite} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
|
||||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
Generate & Send
|
||||||
"hover:border-mti-purple tooltip",
|
</Button>
|
||||||
"transition duration-300 ease-in-out"
|
)}
|
||||||
)}
|
</div>
|
||||||
filterDate={(date) =>
|
</>
|
||||||
moment(date).isAfter(new Date()) &&
|
);
|
||||||
(user.subscriptionExpirationDate
|
|
||||||
? moment(date).isBefore(user.subscriptionExpirationDate)
|
|
||||||
: true)
|
|
||||||
}
|
|
||||||
dateFormat="dd/MM/yyyy"
|
|
||||||
selected={expiryDate}
|
|
||||||
onChange={(date) => setExpiryDate(date)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<label className="text-mti-gray-dim text-base font-normal">
|
|
||||||
Select the type of user they should be
|
|
||||||
</label>
|
|
||||||
{user && (
|
|
||||||
<select
|
|
||||||
defaultValue="student"
|
|
||||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
|
||||||
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"
|
|
||||||
>
|
|
||||||
{Object.keys(USER_TYPE_LABELS)
|
|
||||||
.filter((x) => {
|
|
||||||
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
|
||||||
return checkAccess(user, list, perm);
|
|
||||||
})
|
|
||||||
.map((type) => (
|
|
||||||
<option key={type} value={type}>
|
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
onClick={generateAndInvite}
|
|
||||||
disabled={
|
|
||||||
infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Generate & Send
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
230
src/pages/(admin)/BatchCreateUser.tsx
Normal file
230
src/pages/(admin)/BatchCreateUser.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import {Type as UserType, User} from "@/interfaces/user";
|
||||||
|
import axios from "axios";
|
||||||
|
import {uniqBy} from "lodash";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import {toast} from "react-toastify";
|
||||||
|
import {useFilePicker} from "use-file-picker";
|
||||||
|
import readXlsxFile from "read-excel-file";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import {BsQuestionCircleFill} from "react-icons/bs";
|
||||||
|
import {PermissionType} from "@/interfaces/permissions";
|
||||||
|
import moment from "moment";
|
||||||
|
import {checkAccess} from "@/utils/permissions";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
import ReactDatePicker from "react-datepicker";
|
||||||
|
import clsx from "clsx";
|
||||||
|
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
||||||
|
|
||||||
|
type Type = Exclude<UserType, "admin" | "developer" | "agent" | "mastercorporate">;
|
||||||
|
|
||||||
|
const USER_TYPE_LABELS: {[key in Type]: string} = {
|
||||||
|
student: "Student",
|
||||||
|
teacher: "Teacher",
|
||||||
|
corporate: "Corporate",
|
||||||
|
};
|
||||||
|
|
||||||
|
const USER_TYPE_PERMISSIONS: {
|
||||||
|
[key in Type]: {perm: PermissionType | undefined; list: Type[]};
|
||||||
|
} = {
|
||||||
|
student: {
|
||||||
|
perm: "createCodeStudent",
|
||||||
|
list: [],
|
||||||
|
},
|
||||||
|
teacher: {
|
||||||
|
perm: "createCodeTeacher",
|
||||||
|
list: [],
|
||||||
|
},
|
||||||
|
corporate: {
|
||||||
|
perm: "createCodeCorporate",
|
||||||
|
list: ["student", "teacher"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BatchCreateUser({user}: {user: User}) {
|
||||||
|
const [infos, setInfos] = useState<
|
||||||
|
{
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
passport_id: string;
|
||||||
|
type: Type;
|
||||||
|
demographicInformation: {
|
||||||
|
country: string;
|
||||||
|
passport_id: string;
|
||||||
|
phone: string;
|
||||||
|
};
|
||||||
|
}[]
|
||||||
|
>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||||
|
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
|
||||||
|
);
|
||||||
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
|
const [type, setType] = useState<Type>("student");
|
||||||
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
|
||||||
|
const {users} = useUsers();
|
||||||
|
|
||||||
|
const {openFilePicker, filesContent, clear} = useFilePicker({
|
||||||
|
accept: ".xlsx",
|
||||||
|
multiple: false,
|
||||||
|
readAs: "ArrayBuffer",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||||
|
}, [isExpiryDateEnabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (filesContent.length > 0) {
|
||||||
|
const file = filesContent[0];
|
||||||
|
readXlsxFile(file.content).then((rows) => {
|
||||||
|
try {
|
||||||
|
const information = uniqBy(
|
||||||
|
rows
|
||||||
|
.map((row) => {
|
||||||
|
const [firstName, lastName, country, passport_id, email, phone, group] = row as string[];
|
||||||
|
return EMAIL_REGEX.test(email.toString().trim())
|
||||||
|
? {
|
||||||
|
email: email.toString().trim().toLowerCase(),
|
||||||
|
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
||||||
|
type: type,
|
||||||
|
passport_id: passport_id?.toString().trim() || undefined,
|
||||||
|
groupName: group,
|
||||||
|
demographicInformation: {
|
||||||
|
country: country,
|
||||||
|
passport_id: passport_id?.toString().trim() || undefined,
|
||||||
|
phone,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
})
|
||||||
|
.filter((x) => !!x) as typeof infos,
|
||||||
|
(x) => x.email,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (information.length === 0) {
|
||||||
|
toast.error(
|
||||||
|
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
|
||||||
|
);
|
||||||
|
return clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
setInfos(information);
|
||||||
|
} catch {
|
||||||
|
toast.error(
|
||||||
|
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
|
||||||
|
);
|
||||||
|
return clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [filesContent]);
|
||||||
|
|
||||||
|
const makeUsers = async () => {
|
||||||
|
const newUsers = infos.filter((x) => !users.map((u) => u.email).includes(x.email));
|
||||||
|
if (!confirm(`You are about to add ${newUsers.length}, are you sure you want to continue?`)) return;
|
||||||
|
|
||||||
|
if (newUsers.length > 0) {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const newUser of newUsers) await axios.post("/api/make_user", {...newUser, type, expiryDate});
|
||||||
|
toast.success(`Successfully added ${newUsers.length} user(s)!`);
|
||||||
|
} catch {
|
||||||
|
toast.error("Something went wrong, please try again later!");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
setInfos([]);
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)} title="Excel File Format">
|
||||||
|
<div className="mt-4 flex flex-col gap-2">
|
||||||
|
<span>Please upload an Excel file with the following format:</span>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="border border-neutral-200 px-2 py-1">First Name</th>
|
||||||
|
<th className="border border-neutral-200 px-2 py-1">Last Name</th>
|
||||||
|
<th className="border border-neutral-200 px-2 py-1">Country</th>
|
||||||
|
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
|
||||||
|
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
|
||||||
|
<th className="border border-neutral-200 px-2 py-1">Phone Number</th>
|
||||||
|
<th className="border border-neutral-200 px-2 py-1">Group Name</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
<span className="mt-4">
|
||||||
|
<b>Notes:</b>
|
||||||
|
<ul>
|
||||||
|
<li>- All incorrect e-mails will be ignored;</li>
|
||||||
|
<li>- All already registered e-mails will be ignored;</li>
|
||||||
|
<li>- You may have a header row with the format above, however, it is not necessary;</li>
|
||||||
|
<li>- All of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.</li>
|
||||||
|
</ul>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
<label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label>
|
||||||
|
<div className="tooltip cursor-pointer" data-tip="Excel File Format" onClick={() => setShowHelp(true)}>
|
||||||
|
<BsQuestionCircleFill />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
|
||||||
|
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||||
|
</Button>
|
||||||
|
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
||||||
|
<>
|
||||||
|
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||||
|
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
||||||
|
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
|
||||||
|
Enabled
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
{isExpiryDateEnabled && (
|
||||||
|
<ReactDatePicker
|
||||||
|
className={clsx(
|
||||||
|
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||||
|
"hover:border-mti-purple tooltip",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
)}
|
||||||
|
filterDate={(date) =>
|
||||||
|
moment(date).isAfter(new Date()) &&
|
||||||
|
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
|
||||||
|
}
|
||||||
|
dateFormat="dd/MM/yyyy"
|
||||||
|
selected={expiryDate}
|
||||||
|
onChange={(date) => setExpiryDate(date)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<label className="text-mti-gray-dim text-base font-normal">Select the type of user they should be</label>
|
||||||
|
{user && (
|
||||||
|
<select
|
||||||
|
defaultValue="student"
|
||||||
|
onChange={(e) => setType(e.target.value as Type)}
|
||||||
|
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none">
|
||||||
|
{Object.keys(USER_TYPE_LABELS).map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<Button className="my-auto" onClick={makeUsers} disabled={infos.length === 0}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,197 +1,162 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||||
import { Type, User } from "@/interfaces/user";
|
import {Type, User} from "@/interfaces/user";
|
||||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { capitalize } from "lodash";
|
import {capitalize} from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { useEffect, useState } from "react";
|
import {useEffect, useState} from "react";
|
||||||
import ReactDatePicker from "react-datepicker";
|
import ReactDatePicker from "react-datepicker";
|
||||||
import { toast } from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
import { checkAccess } from "@/utils/permissions";
|
import {checkAccess} from "@/utils/permissions";
|
||||||
import { PermissionType } from "@/interfaces/permissions";
|
import {PermissionType} from "@/interfaces/permissions";
|
||||||
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
|
||||||
const USER_TYPE_PERMISSIONS: {
|
const USER_TYPE_PERMISSIONS: {
|
||||||
[key in Type]: { perm: PermissionType | undefined; list: Type[] };
|
[key in Type]: {perm: PermissionType | undefined; list: Type[]};
|
||||||
} = {
|
} = {
|
||||||
student: {
|
student: {
|
||||||
perm: "createCodeStudent",
|
perm: "createCodeStudent",
|
||||||
list: [],
|
list: [],
|
||||||
},
|
},
|
||||||
teacher: {
|
teacher: {
|
||||||
perm: "createCodeTeacher",
|
perm: "createCodeTeacher",
|
||||||
list: [],
|
list: [],
|
||||||
},
|
},
|
||||||
agent: {
|
agent: {
|
||||||
perm: "createCodeCountryManager",
|
perm: "createCodeCountryManager",
|
||||||
list: [],
|
list: [],
|
||||||
},
|
},
|
||||||
corporate: {
|
corporate: {
|
||||||
perm: "createCodeCorporate",
|
perm: "createCodeCorporate",
|
||||||
list: ["student", "teacher"],
|
list: ["student", "teacher"],
|
||||||
},
|
},
|
||||||
mastercorporate: {
|
mastercorporate: {
|
||||||
perm: undefined,
|
perm: undefined,
|
||||||
list: ["student", "teacher", "corporate"],
|
list: ["student", "teacher", "corporate"],
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
perm: "createCodeAdmin",
|
perm: "createCodeAdmin",
|
||||||
list: [
|
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
|
||||||
"student",
|
},
|
||||||
"teacher",
|
developer: {
|
||||||
"agent",
|
perm: undefined,
|
||||||
"corporate",
|
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
|
||||||
"admin",
|
},
|
||||||
"mastercorporate",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
developer: {
|
|
||||||
perm: undefined,
|
|
||||||
list: [
|
|
||||||
"student",
|
|
||||||
"teacher",
|
|
||||||
"agent",
|
|
||||||
"corporate",
|
|
||||||
"admin",
|
|
||||||
"developer",
|
|
||||||
"mastercorporate",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CodeGenerator({ user }: { user: User }) {
|
export default function CodeGenerator({user}: {user: User}) {
|
||||||
const [generatedCode, setGeneratedCode] = useState<string>();
|
const [generatedCode, setGeneratedCode] = useState<string>();
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
const [expiryDate, setExpiryDate] = useState<Date | null>(
|
||||||
user?.subscriptionExpirationDate
|
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
|
||||||
? moment(user.subscriptionExpirationDate).toDate()
|
);
|
||||||
: null
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
);
|
const [type, setType] = useState<Type>("student");
|
||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
const {permissions} = usePermissions(user?.id || "");
|
||||||
const [type, setType] = useState<Type>("student");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isExpiryDateEnabled) setExpiryDate(null);
|
if (!isExpiryDateEnabled) setExpiryDate(null);
|
||||||
}, [isExpiryDateEnabled]);
|
}, [isExpiryDateEnabled]);
|
||||||
|
|
||||||
const generateCode = (type: Type) => {
|
const generateCode = (type: Type) => {
|
||||||
const uid = new ShortUniqueId();
|
const uid = new ShortUniqueId();
|
||||||
const code = uid.randomUUID(6);
|
const code = uid.randomUUID(6);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post("/api/code", { type, codes: [code], expiryDate })
|
.post("/api/code", {type, codes: [code], expiryDate})
|
||||||
.then(({ data, status }) => {
|
.then(({data, status}) => {
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
toast.success(`Successfully generated a ${capitalize(type)} code!`, {
|
toast.success(`Successfully generated a ${capitalize(type)} code!`, {
|
||||||
toastId: "success",
|
toastId: "success",
|
||||||
});
|
});
|
||||||
setGeneratedCode(code);
|
setGeneratedCode(code);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 403) {
|
if (status === 403) {
|
||||||
toast.error(data.reason, { toastId: "forbidden" });
|
toast.error(data.reason, {toastId: "forbidden"});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(({ response: { status, data } }) => {
|
.catch(({response: {status, data}}) => {
|
||||||
if (status === 403) {
|
if (status === 403) {
|
||||||
toast.error(data.reason, { toastId: "forbidden" });
|
toast.error(data.reason, {toastId: "forbidden"});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error(`Something went wrong, please try again later!`, {
|
toast.error(`Something went wrong, please try again later!`, {
|
||||||
toastId: "error",
|
toastId: "error",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">
|
<label className="font-normal text-base text-mti-gray-dim">User Code Generator</label>
|
||||||
User Code Generator
|
{user && (
|
||||||
</label>
|
<select
|
||||||
{user && (
|
defaultValue="student"
|
||||||
<select
|
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||||
defaultValue="student"
|
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
||||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
{Object.keys(USER_TYPE_LABELS)
|
||||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"
|
.filter((x) => {
|
||||||
>
|
const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
|
||||||
{Object.keys(USER_TYPE_LABELS)
|
return checkAccess(user, list, permissions, perm);
|
||||||
.filter((x) => {
|
})
|
||||||
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
.map((type) => (
|
||||||
return checkAccess(user, list, perm);
|
<option key={type} value={type}>
|
||||||
})
|
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||||
.map((type) => (
|
</option>
|
||||||
<option key={type} value={type}>
|
))}
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
</select>
|
||||||
</option>
|
)}
|
||||||
))}
|
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
|
||||||
</select>
|
<>
|
||||||
)}
|
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
||||||
{user &&
|
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
|
||||||
checkAccess(user, ["developer", "admin", "corporate"]) && (
|
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
|
||||||
<>
|
Enabled
|
||||||
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
|
</Checkbox>
|
||||||
<label className="text-mti-gray-dim text-base font-normal">
|
</div>
|
||||||
Expiry Date
|
{isExpiryDateEnabled && (
|
||||||
</label>
|
<ReactDatePicker
|
||||||
<Checkbox
|
className={clsx(
|
||||||
isChecked={isExpiryDateEnabled}
|
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
||||||
onChange={setIsExpiryDateEnabled}
|
"hover:border-mti-purple tooltip",
|
||||||
disabled={!!user.subscriptionExpirationDate}
|
"transition duration-300 ease-in-out",
|
||||||
>
|
)}
|
||||||
Enabled
|
filterDate={(date) =>
|
||||||
</Checkbox>
|
moment(date).isAfter(new Date()) &&
|
||||||
</div>
|
(user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
|
||||||
{isExpiryDateEnabled && (
|
}
|
||||||
<ReactDatePicker
|
dateFormat="dd/MM/yyyy"
|
||||||
className={clsx(
|
selected={expiryDate}
|
||||||
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
|
onChange={(date) => setExpiryDate(date)}
|
||||||
"hover:border-mti-purple tooltip",
|
/>
|
||||||
"transition duration-300 ease-in-out"
|
)}
|
||||||
)}
|
</>
|
||||||
filterDate={(date) =>
|
)}
|
||||||
moment(date).isAfter(new Date()) &&
|
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && (
|
||||||
(user.subscriptionExpirationDate
|
<Button onClick={() => generateCode(type)} disabled={isExpiryDateEnabled ? !expiryDate : false}>
|
||||||
? moment(date).isBefore(user.subscriptionExpirationDate)
|
Generate
|
||||||
: true)
|
</Button>
|
||||||
}
|
)}
|
||||||
dateFormat="dd/MM/yyyy"
|
<label className="font-normal text-base text-mti-gray-dim">Generated Code:</label>
|
||||||
selected={expiryDate}
|
<div
|
||||||
onChange={(date) => setExpiryDate(date)}
|
className={clsx(
|
||||||
/>
|
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
)}
|
"hover:border-mti-purple tooltip",
|
||||||
</>
|
"transition duration-300 ease-in-out",
|
||||||
)}
|
)}
|
||||||
<Button
|
data-tip="Click to copy"
|
||||||
onClick={() => generateCode(type)}
|
onClick={() => {
|
||||||
disabled={isExpiryDateEnabled ? !expiryDate : false}
|
if (generatedCode) navigator.clipboard.writeText(generatedCode);
|
||||||
>
|
}}>
|
||||||
Generate
|
{generatedCode}
|
||||||
</Button>
|
</div>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">
|
{generatedCode && <span className="text-sm text-mti-gray-dim font-light">Give this code to the user to complete their registration</span>}
|
||||||
Generated Code:
|
</div>
|
||||||
</label>
|
);
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
|
||||||
"hover:border-mti-purple tooltip",
|
|
||||||
"transition duration-300 ease-in-out"
|
|
||||||
)}
|
|
||||||
data-tip="Click to copy"
|
|
||||||
onClick={() => {
|
|
||||||
if (generatedCode) navigator.clipboard.writeText(generatedCode);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{generatedCode}
|
|
||||||
</div>
|
|
||||||
{generatedCode && (
|
|
||||||
<span className="text-sm text-mti-gray-dim font-light">
|
|
||||||
Give this code to the user to complete their registration
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,355 +4,306 @@ import Select from "@/components/Low/Select";
|
|||||||
import useCodes from "@/hooks/useCodes";
|
import useCodes from "@/hooks/useCodes";
|
||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import { Code, User } from "@/interfaces/user";
|
import {Code, User} from "@/interfaces/user";
|
||||||
import { USER_TYPE_LABELS } from "@/resources/user";
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
import {
|
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
||||||
createColumnHelper,
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
useReactTable,
|
|
||||||
} from "@tanstack/react-table";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { useEffect, useState, useMemo } from "react";
|
import {useEffect, useState, useMemo} from "react";
|
||||||
import { BsTrash } from "react-icons/bs";
|
import {BsTrash} from "react-icons/bs";
|
||||||
import { toast } from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import ReactDatePicker from "react-datepicker";
|
import ReactDatePicker from "react-datepicker";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import {checkAccess} from "@/utils/permissions";
|
||||||
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<Code>();
|
const columnHelper = createColumnHelper<Code>();
|
||||||
|
|
||||||
const CreatorCell = ({ id, users }: { id: string; users: User[] }) => {
|
const CreatorCell = ({id, users}: {id: string; users: User[]}) => {
|
||||||
const [creatorUser, setCreatorUser] = useState<User>();
|
const [creatorUser, setCreatorUser] = useState<User>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCreatorUser(users.find((x) => x.id === id));
|
setCreatorUser(users.find((x) => x.id === id));
|
||||||
}, [id, users]);
|
}, [id, users]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(creatorUser?.type === "corporate"
|
{(creatorUser?.type === "corporate" ? creatorUser?.corporateInformation?.companyInformation?.name : creatorUser?.name || "N/A") || "N/A"}{" "}
|
||||||
? creatorUser?.corporateInformation?.companyInformation?.name
|
{creatorUser && `(${USER_TYPE_LABELS[creatorUser.type]})`}
|
||||||
: creatorUser?.name || "N/A") || "N/A"}{" "}
|
</>
|
||||||
{creatorUser && `(${USER_TYPE_LABELS[creatorUser.type]})`}
|
);
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CodeList({ user }: { user: User }) {
|
export default function CodeList({user}: {user: User}) {
|
||||||
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
|
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
|
||||||
|
|
||||||
const [filteredCorporate, setFilteredCorporate] = useState<User | undefined>(
|
const [filteredCorporate, setFilteredCorporate] = useState<User | undefined>(user?.type === "corporate" ? user : undefined);
|
||||||
user?.type === "corporate" ? user : undefined
|
const [filterAvailability, setFilterAvailability] = useState<"in-use" | "unused">();
|
||||||
);
|
|
||||||
const [filterAvailability, setFilterAvailability] = useState<
|
|
||||||
"in-use" | "unused"
|
|
||||||
>();
|
|
||||||
|
|
||||||
// const [filteredCodes, setFilteredCodes] = useState<Code[]>([]);
|
const {permissions} = usePermissions(user?.id || "");
|
||||||
|
|
||||||
const { users } = useUsers();
|
// const [filteredCodes, setFilteredCodes] = useState<Code[]>([]);
|
||||||
const { codes, reload } = useCodes(
|
|
||||||
user?.type === "corporate" ? user?.id : undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const [startDate, setStartDate] = useState<Date | null>(moment("01/01/2023").toDate());
|
const {users} = useUsers();
|
||||||
|
const {codes, reload} = useCodes(user?.type === "corporate" ? user?.id : undefined);
|
||||||
|
|
||||||
|
const [startDate, setStartDate] = useState<Date | null>(moment("01/01/2023").toDate());
|
||||||
const [endDate, setEndDate] = useState<Date | null>(moment().endOf("day").toDate());
|
const [endDate, setEndDate] = useState<Date | null>(moment().endOf("day").toDate());
|
||||||
const filteredCodes = useMemo(() => {
|
const filteredCodes = useMemo(() => {
|
||||||
return codes.filter((x) => {
|
return codes.filter((x) => {
|
||||||
// TODO: if the expiry date is missing, it does not make sense to filter by date
|
// TODO: if the expiry date is missing, it does not make sense to filter by date
|
||||||
// so we need to find a way to handle this edge case
|
// so we need to find a way to handle this edge case
|
||||||
if(startDate && endDate && x.expiryDate) {
|
if (startDate && endDate && x.expiryDate) {
|
||||||
const date = moment(x.expiryDate);
|
const date = moment(x.expiryDate);
|
||||||
if(date.isBefore(startDate) || date.isAfter(endDate)) {
|
if (date.isBefore(startDate) || date.isAfter(endDate)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (filteredCorporate && x.creator !== filteredCorporate.id) return false;
|
if (filteredCorporate && x.creator !== filteredCorporate.id) return false;
|
||||||
if (filterAvailability) {
|
if (filterAvailability) {
|
||||||
if (filterAvailability === "in-use" && !x.userId) return false;
|
if (filterAvailability === "in-use" && !x.userId) return false;
|
||||||
if (filterAvailability === "unused" && x.userId) return false;
|
if (filterAvailability === "unused" && x.userId) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [codes, startDate, endDate, filteredCorporate, filterAvailability]);
|
}, [codes, startDate, endDate, filteredCorporate, filterAvailability]);
|
||||||
|
|
||||||
const toggleCode = (id: string) => {
|
const toggleCode = (id: string) => {
|
||||||
setSelectedCodes((prev) =>
|
setSelectedCodes((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
};
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAllCodes = (checked: boolean) => {
|
const toggleAllCodes = (checked: boolean) => {
|
||||||
if (checked)
|
if (checked) return setSelectedCodes(filteredCodes.filter((x) => !x.userId).map((x) => x.code));
|
||||||
return setSelectedCodes(
|
|
||||||
filteredCodes.filter((x) => !x.userId).map((x) => x.code)
|
|
||||||
);
|
|
||||||
|
|
||||||
return setSelectedCodes([]);
|
return setSelectedCodes([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteCodes = async (codes: string[]) => {
|
const deleteCodes = async (codes: string[]) => {
|
||||||
if (
|
if (!confirm(`Are you sure you want to delete these ${codes.length} code(s)?`)) return;
|
||||||
!confirm(`Are you sure you want to delete these ${codes.length} code(s)?`)
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
codes.forEach((code) => params.append("code", code));
|
codes.forEach((code) => params.append("code", code));
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.delete(`/api/code?${params.toString()}`)
|
.delete(`/api/code?${params.toString()}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`Deleted the codes!`);
|
toast.success(`Deleted the codes!`);
|
||||||
setSelectedCodes([]);
|
setSelectedCodes([]);
|
||||||
})
|
})
|
||||||
.catch((reason) => {
|
.catch((reason) => {
|
||||||
if (reason.response.status === 404) {
|
if (reason.response.status === 404) {
|
||||||
toast.error("Code not found!");
|
toast.error("Code not found!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reason.response.status === 403) {
|
if (reason.response.status === 403) {
|
||||||
toast.error("You do not have permission to delete this code!");
|
toast.error("You do not have permission to delete this code!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error("Something went wrong, please try again later.");
|
toast.error("Something went wrong, please try again later.");
|
||||||
})
|
})
|
||||||
.finally(reload);
|
.finally(reload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteCode = async (code: Code) => {
|
const deleteCode = async (code: Code) => {
|
||||||
if (!confirm(`Are you sure you want to delete this "${code.code}" code?`))
|
if (!confirm(`Are you sure you want to delete this "${code.code}" code?`)) return;
|
||||||
return;
|
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.delete(`/api/code/${code.code}`)
|
.delete(`/api/code/${code.code}`)
|
||||||
.then(() => toast.success(`Deleted the "${code.code}" exam`))
|
.then(() => toast.success(`Deleted the "${code.code}" exam`))
|
||||||
.catch((reason) => {
|
.catch((reason) => {
|
||||||
if (reason.response.status === 404) {
|
if (reason.response.status === 404) {
|
||||||
toast.error("Code not found!");
|
toast.error("Code not found!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reason.response.status === 403) {
|
if (reason.response.status === 403) {
|
||||||
toast.error("You do not have permission to delete this code!");
|
toast.error("You do not have permission to delete this code!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error("Something went wrong, please try again later.");
|
toast.error("Something went wrong, please try again later.");
|
||||||
})
|
})
|
||||||
.finally(reload);
|
.finally(reload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultColumns = [
|
const allowedToDelete = checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "deleteCodes");
|
||||||
columnHelper.accessor("code", {
|
|
||||||
id: "codeCheckbox",
|
|
||||||
header: () => (
|
|
||||||
<Checkbox
|
|
||||||
disabled={filteredCodes.filter((x) => !x.userId).length === 0}
|
|
||||||
isChecked={
|
|
||||||
selectedCodes.length ===
|
|
||||||
filteredCodes.filter((x) => !x.userId).length &&
|
|
||||||
filteredCodes.filter((x) => !x.userId).length > 0
|
|
||||||
}
|
|
||||||
onChange={(checked) => toggleAllCodes(checked)}
|
|
||||||
>
|
|
||||||
{""}
|
|
||||||
</Checkbox>
|
|
||||||
),
|
|
||||||
cell: (info) =>
|
|
||||||
!info.row.original.userId ? (
|
|
||||||
<Checkbox
|
|
||||||
isChecked={selectedCodes.includes(info.getValue())}
|
|
||||||
onChange={() => toggleCode(info.getValue())}
|
|
||||||
>
|
|
||||||
{""}
|
|
||||||
</Checkbox>
|
|
||||||
) : null,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("code", {
|
|
||||||
header: "Code",
|
|
||||||
cell: (info) => info.getValue(),
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("creationDate", {
|
|
||||||
header: "Creation Date",
|
|
||||||
cell: (info) =>
|
|
||||||
info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A",
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("email", {
|
|
||||||
header: "Invited E-mail",
|
|
||||||
cell: (info) => info.getValue() || "N/A",
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("creator", {
|
|
||||||
header: "Creator",
|
|
||||||
cell: (info) => <CreatorCell id={info.getValue()} users={users} />,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("userId", {
|
|
||||||
header: "Availability",
|
|
||||||
cell: (info) =>
|
|
||||||
info.getValue() ? (
|
|
||||||
<span className="flex gap-1 items-center text-mti-green">
|
|
||||||
<div className="w-2 h-2 rounded-full bg-mti-green" /> In Use
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="flex gap-1 items-center text-mti-red">
|
|
||||||
<div className="w-2 h-2 rounded-full bg-mti-red" /> Unused
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
header: "",
|
|
||||||
id: "actions",
|
|
||||||
cell: ({ row }: { row: { original: Code } }) => {
|
|
||||||
return (
|
|
||||||
<div className="flex gap-4">
|
|
||||||
{!row.original.userId && (
|
|
||||||
<div
|
|
||||||
data-tip="Delete"
|
|
||||||
className="cursor-pointer tooltip"
|
|
||||||
onClick={() => deleteCode(row.original)}
|
|
||||||
>
|
|
||||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const table = useReactTable({
|
const defaultColumns = [
|
||||||
data: filteredCodes,
|
columnHelper.accessor("code", {
|
||||||
columns: defaultColumns,
|
id: "codeCheckbox",
|
||||||
getCoreRowModel: getCoreRowModel(),
|
header: () => (
|
||||||
});
|
<Checkbox
|
||||||
|
disabled={filteredCodes.filter((x) => !x.userId).length === 0}
|
||||||
|
isChecked={
|
||||||
|
selectedCodes.length === filteredCodes.filter((x) => !x.userId).length && filteredCodes.filter((x) => !x.userId).length > 0
|
||||||
|
}
|
||||||
|
onChange={(checked) => toggleAllCodes(checked)}>
|
||||||
|
{""}
|
||||||
|
</Checkbox>
|
||||||
|
),
|
||||||
|
cell: (info) =>
|
||||||
|
!info.row.original.userId ? (
|
||||||
|
<Checkbox isChecked={selectedCodes.includes(info.getValue())} onChange={() => toggleCode(info.getValue())}>
|
||||||
|
{""}
|
||||||
|
</Checkbox>
|
||||||
|
) : null,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("code", {
|
||||||
|
header: "Code",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("creationDate", {
|
||||||
|
header: "Creation Date",
|
||||||
|
cell: (info) => (info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A"),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("email", {
|
||||||
|
header: "Invited E-mail",
|
||||||
|
cell: (info) => info.getValue() || "N/A",
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("creator", {
|
||||||
|
header: "Creator",
|
||||||
|
cell: (info) => <CreatorCell id={info.getValue()} users={users} />,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("userId", {
|
||||||
|
header: "Availability",
|
||||||
|
cell: (info) =>
|
||||||
|
info.getValue() ? (
|
||||||
|
<span className="flex gap-1 items-center text-mti-green">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-mti-green" /> In Use
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex gap-1 items-center text-mti-red">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-mti-red" /> Unused
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
header: "",
|
||||||
|
id: "actions",
|
||||||
|
cell: ({row}: {row: {original: Code}}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{allowedToDelete && !row.original.userId && (
|
||||||
|
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteCode(row.original)}>
|
||||||
|
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
const table = useReactTable({
|
||||||
<>
|
data: filteredCodes,
|
||||||
<div className="flex items-center justify-between pb-4 pt-1">
|
columns: defaultColumns,
|
||||||
<div className="flex items-center gap-4">
|
getCoreRowModel: getCoreRowModel(),
|
||||||
<Select
|
});
|
||||||
className="!w-96 !py-1"
|
|
||||||
disabled={user?.type === "corporate"}
|
return (
|
||||||
isClearable
|
<>
|
||||||
placeholder="Corporate"
|
<div className="flex items-center justify-between pb-4 pt-1">
|
||||||
value={
|
<div className="flex items-center gap-4">
|
||||||
filteredCorporate
|
<Select
|
||||||
? {
|
className="!w-96 !py-1"
|
||||||
label: `${
|
disabled={user?.type === "corporate"}
|
||||||
filteredCorporate.type === "corporate"
|
isClearable
|
||||||
? filteredCorporate.corporateInformation
|
placeholder="Corporate"
|
||||||
?.companyInformation?.name || filteredCorporate.name
|
value={
|
||||||
: filteredCorporate.name
|
filteredCorporate
|
||||||
} (${USER_TYPE_LABELS[filteredCorporate.type]})`,
|
? {
|
||||||
value: filteredCorporate.id,
|
label: `${
|
||||||
}
|
filteredCorporate.type === "corporate"
|
||||||
: null
|
? filteredCorporate.corporateInformation?.companyInformation?.name || filteredCorporate.name
|
||||||
}
|
: filteredCorporate.name
|
||||||
options={users
|
} (${USER_TYPE_LABELS[filteredCorporate.type]})`,
|
||||||
.filter((x) =>
|
value: filteredCorporate.id,
|
||||||
["admin", "developer", "corporate"].includes(x.type)
|
}
|
||||||
)
|
: null
|
||||||
.map((x) => ({
|
}
|
||||||
label: `${
|
options={users
|
||||||
x.type === "corporate"
|
.filter((x) => ["admin", "developer", "corporate"].includes(x.type))
|
||||||
? x.corporateInformation?.companyInformation?.name || x.name
|
.map((x) => ({
|
||||||
: x.name
|
label: `${x.type === "corporate" ? x.corporateInformation?.companyInformation?.name || x.name : x.name} (${
|
||||||
} (${USER_TYPE_LABELS[x.type]})`,
|
USER_TYPE_LABELS[x.type]
|
||||||
value: x.id,
|
})`,
|
||||||
user: x,
|
value: x.id,
|
||||||
}))}
|
user: x,
|
||||||
onChange={(value) =>
|
}))}
|
||||||
setFilteredCorporate(
|
onChange={(value) => setFilteredCorporate(value ? users.find((x) => x.id === value?.value) : undefined)}
|
||||||
value ? users.find((x) => x.id === value?.value) : undefined
|
/>
|
||||||
)
|
<Select
|
||||||
}
|
className="!w-96 !py-1"
|
||||||
/>
|
placeholder="Availability"
|
||||||
<Select
|
isClearable
|
||||||
className="!w-96 !py-1"
|
options={[
|
||||||
placeholder="Availability"
|
{label: "In Use", value: "in-use"},
|
||||||
isClearable
|
{label: "Unused", value: "unused"},
|
||||||
options={[
|
]}
|
||||||
{ label: "In Use", value: "in-use" },
|
onChange={(value) => setFilterAvailability(value ? (value.value as typeof filterAvailability) : undefined)}
|
||||||
{ label: "Unused", value: "unused" },
|
/>
|
||||||
]}
|
<ReactDatePicker
|
||||||
onChange={(value) =>
|
dateFormat="dd/MM/yyyy"
|
||||||
setFilterAvailability(
|
className="px-4 py-6 w-full text-sm text-center font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
value ? (value.value as typeof filterAvailability) : undefined
|
selected={startDate}
|
||||||
)
|
startDate={startDate}
|
||||||
}
|
endDate={endDate}
|
||||||
/>
|
selectsRange
|
||||||
<ReactDatePicker
|
showMonthDropdown
|
||||||
dateFormat="dd/MM/yyyy"
|
filterDate={(date: Date) => moment(date).isSameOrBefore(moment(new Date()))}
|
||||||
className="px-4 py-6 w-full text-sm text-center font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none"
|
onChange={([initialDate, finalDate]: [Date, Date]) => {
|
||||||
selected={startDate}
|
setStartDate(initialDate ?? moment("01/01/2023").toDate());
|
||||||
startDate={startDate}
|
if (finalDate) {
|
||||||
endDate={endDate}
|
// basicly selecting a final day works as if I'm selecting the first
|
||||||
selectsRange
|
// minute of that day. this way it covers the whole day
|
||||||
showMonthDropdown
|
setEndDate(moment(finalDate).endOf("day").toDate());
|
||||||
filterDate={(date: Date) =>
|
return;
|
||||||
moment(date).isSameOrBefore(moment(new Date()))
|
}
|
||||||
}
|
setEndDate(null);
|
||||||
onChange={([initialDate, finalDate]: [Date, Date]) => {
|
}}
|
||||||
setStartDate(initialDate ?? moment("01/01/2023").toDate());
|
/>
|
||||||
if (finalDate) {
|
</div>
|
||||||
// basicly selecting a final day works as if I'm selecting the first
|
{allowedToDelete && (
|
||||||
// minute of that day. this way it covers the whole day
|
<div className="flex gap-4 items-center">
|
||||||
setEndDate(moment(finalDate).endOf("day").toDate());
|
<span>{selectedCodes.length} code(s) selected</span>
|
||||||
return;
|
<Button
|
||||||
}
|
disabled={selectedCodes.length === 0}
|
||||||
setEndDate(null);
|
variant="outline"
|
||||||
}}
|
color="red"
|
||||||
/>
|
className="!py-1 px-10"
|
||||||
</div>
|
onClick={() => deleteCodes(selectedCodes)}>
|
||||||
<div className="flex gap-4 items-center">
|
Delete
|
||||||
<span>{selectedCodes.length} code(s) selected</span>
|
</Button>
|
||||||
<Button
|
</div>
|
||||||
disabled={selectedCodes.length === 0}
|
)}
|
||||||
variant="outline"
|
</div>
|
||||||
color="red"
|
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||||
className="!py-1 px-10"
|
<thead>
|
||||||
onClick={() => deleteCodes(selectedCodes)}
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
>
|
<tr key={headerGroup.id}>
|
||||||
Delete
|
{headerGroup.headers.map((header) => (
|
||||||
</Button>
|
<th className="p-4 text-left" key={header.id}>
|
||||||
</div>
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
</div>
|
</th>
|
||||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
))}
|
||||||
<thead>
|
</tr>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
))}
|
||||||
<tr key={headerGroup.id}>
|
</thead>
|
||||||
{headerGroup.headers.map((header) => (
|
<tbody className="px-2">
|
||||||
<th className="p-4 text-left" key={header.id}>
|
{table.getRowModel().rows.map((row) => (
|
||||||
{header.isPlaceholder
|
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
||||||
? null
|
{row.getVisibleCells().map((cell) => (
|
||||||
: flexRender(
|
<td className="px-4 py-2" key={cell.id}>
|
||||||
header.column.columnDef.header,
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
header.getContext()
|
</td>
|
||||||
)}
|
))}
|
||||||
</th>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tbody>
|
||||||
))}
|
</table>
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,154 +1,223 @@
|
|||||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
import { useMemo } from "react";
|
||||||
|
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||||
import useExams from "@/hooks/useExams";
|
import useExams from "@/hooks/useExams";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {Module} from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import {Exam} from "@/interfaces/exam";
|
import { Exam } from "@/interfaces/exam";
|
||||||
import {Type, User} from "@/interfaces/user";
|
import { Type, User } from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {getExamById} from "@/utils/exams";
|
import { getExamById } from "@/utils/exams";
|
||||||
import {countExercises} from "@/utils/moduleUtils";
|
import { countExercises } from "@/utils/moduleUtils";
|
||||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
import {
|
||||||
|
createColumnHelper,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize} from "lodash";
|
import { capitalize } from "lodash";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {BsCheck, BsTrash, BsUpload} from "react-icons/bs";
|
import { BsCheck, BsTrash, BsUpload } from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
const CLASSES: {[key in Module]: string} = {
|
const CLASSES: { [key in Module]: string } = {
|
||||||
reading: "text-ielts-reading",
|
reading: "text-ielts-reading",
|
||||||
listening: "text-ielts-listening",
|
listening: "text-ielts-listening",
|
||||||
speaking: "text-ielts-speaking",
|
speaking: "text-ielts-speaking",
|
||||||
writing: "text-ielts-writing",
|
writing: "text-ielts-writing",
|
||||||
level: "text-ielts-level",
|
level: "text-ielts-level",
|
||||||
};
|
};
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<Exam>();
|
const columnHelper = createColumnHelper<Exam>();
|
||||||
|
|
||||||
export default function ExamList({user}: {user: User}) {
|
export default function ExamList({ user }: { user: User }) {
|
||||||
const {exams, reload} = useExams();
|
const { exams, reload } = useExams();
|
||||||
|
const { users } = useUsers();
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
const parsedExams = useMemo(() => {
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
return exams.map((exam) => {
|
||||||
|
if (exam.createdBy) {
|
||||||
|
const user = users.find((u) => u.id === exam.createdBy);
|
||||||
|
if (!user) return exam;
|
||||||
|
|
||||||
const router = useRouter();
|
return {
|
||||||
|
...exam,
|
||||||
|
createdBy: user.type === "developer" ? "system" : user.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const loadExam = async (module: Module, examId: string) => {
|
return exam;
|
||||||
const exam = await getExamById(module, examId.trim());
|
});
|
||||||
if (!exam) {
|
}, [exams, users]);
|
||||||
toast.error("Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID", {
|
|
||||||
toastId: "invalid-exam-id",
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
const setExams = useExamStore((state) => state.setExams);
|
||||||
}
|
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||||
|
|
||||||
setExams([exam]);
|
const router = useRouter();
|
||||||
setSelectedModules([module]);
|
|
||||||
|
|
||||||
router.push("/exercises");
|
const loadExam = async (module: Module, examId: string) => {
|
||||||
};
|
const exam = await getExamById(module, examId.trim());
|
||||||
|
if (!exam) {
|
||||||
|
toast.error(
|
||||||
|
"Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID",
|
||||||
|
{
|
||||||
|
toastId: "invalid-exam-id",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const deleteExam = async (exam: Exam) => {
|
return;
|
||||||
if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return;
|
}
|
||||||
|
|
||||||
axios
|
setExams([exam]);
|
||||||
.delete(`/api/exam/${exam.module}/${exam.id}`)
|
setSelectedModules([module]);
|
||||||
.then(() => toast.success(`Deleted the "${exam.id}" exam`))
|
|
||||||
.catch((reason) => {
|
|
||||||
if (reason.response.status === 404) {
|
|
||||||
toast.error("Exam not found!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reason.response.status === 403) {
|
router.push("/exercises");
|
||||||
toast.error("You do not have permission to delete this exam!");
|
};
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.error("Something went wrong, please try again later.");
|
const deleteExam = async (exam: Exam) => {
|
||||||
})
|
if (
|
||||||
.finally(reload);
|
!confirm(
|
||||||
};
|
`Are you sure you want to delete this ${capitalize(exam.module)} exam?`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
const getTotalExercises = (exam: Exam) => {
|
axios
|
||||||
if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") {
|
.delete(`/api/exam/${exam.module}/${exam.id}`)
|
||||||
return countExercises(exam.parts.flatMap((x) => x.exercises));
|
.then(() => toast.success(`Deleted the "${exam.id}" exam`))
|
||||||
}
|
.catch((reason) => {
|
||||||
|
if (reason.response.status === 404) {
|
||||||
|
toast.error("Exam not found!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return countExercises(exam.exercises);
|
if (reason.response.status === 403) {
|
||||||
};
|
toast.error("You do not have permission to delete this exam!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const defaultColumns = [
|
toast.error("Something went wrong, please try again later.");
|
||||||
columnHelper.accessor("id", {
|
})
|
||||||
header: "ID",
|
.finally(reload);
|
||||||
cell: (info) => info.getValue(),
|
};
|
||||||
}),
|
|
||||||
columnHelper.accessor("module", {
|
|
||||||
header: "Module",
|
|
||||||
cell: (info) => <span className={CLASSES[info.getValue()]}>{capitalize(info.getValue())}</span>,
|
|
||||||
}),
|
|
||||||
columnHelper.accessor((x) => getTotalExercises(x), {
|
|
||||||
header: "Exercises",
|
|
||||||
cell: (info) => info.getValue(),
|
|
||||||
}),
|
|
||||||
columnHelper.accessor("minTimer", {
|
|
||||||
header: "Timer",
|
|
||||||
cell: (info) => <>{info.getValue()} minute(s)</>,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
header: "",
|
|
||||||
id: "actions",
|
|
||||||
cell: ({row}: {row: {original: Exam}}) => {
|
|
||||||
return (
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<div
|
|
||||||
data-tip="Load exam"
|
|
||||||
className="cursor-pointer tooltip"
|
|
||||||
onClick={async () => await loadExam(row.original.module, row.original.id)}>
|
|
||||||
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
|
||||||
</div>
|
|
||||||
{PERMISSIONS.examManagement.delete.includes(user.type) && (
|
|
||||||
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteExam(row.original)}>
|
|
||||||
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const table = useReactTable({
|
const getTotalExercises = (exam: Exam) => {
|
||||||
data: exams,
|
if (
|
||||||
columns: defaultColumns,
|
exam.module === "reading" ||
|
||||||
getCoreRowModel: getCoreRowModel(),
|
exam.module === "listening" ||
|
||||||
});
|
exam.module === "level"
|
||||||
|
) {
|
||||||
|
return countExercises(exam.parts.flatMap((x) => x.exercises));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return countExercises(exam.exercises);
|
||||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
};
|
||||||
<thead>
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
const defaultColumns = [
|
||||||
<tr key={headerGroup.id}>
|
columnHelper.accessor("id", {
|
||||||
{headerGroup.headers.map((header) => (
|
header: "ID",
|
||||||
<th className="p-4 text-left" key={header.id}>
|
cell: (info) => info.getValue(),
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
}),
|
||||||
</th>
|
columnHelper.accessor("module", {
|
||||||
))}
|
header: "Module",
|
||||||
</tr>
|
cell: (info) => (
|
||||||
))}
|
<span className={CLASSES[info.getValue()]}>
|
||||||
</thead>
|
{capitalize(info.getValue())}
|
||||||
<tbody className="px-2">
|
</span>
|
||||||
{table.getRowModel().rows.map((row) => (
|
),
|
||||||
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
|
}),
|
||||||
{row.getVisibleCells().map((cell) => (
|
columnHelper.accessor((x) => getTotalExercises(x), {
|
||||||
<td className="px-4 py-2" key={cell.id}>
|
header: "Exercises",
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
cell: (info) => info.getValue(),
|
||||||
</td>
|
}),
|
||||||
))}
|
columnHelper.accessor("minTimer", {
|
||||||
</tr>
|
header: "Timer",
|
||||||
))}
|
cell: (info) => <>{info.getValue()} minute(s)</>,
|
||||||
</tbody>
|
}),
|
||||||
</table>
|
columnHelper.accessor("createdAt", {
|
||||||
);
|
header: "Created At",
|
||||||
|
cell: (info) => {
|
||||||
|
const value = info.getValue();
|
||||||
|
if (value) {
|
||||||
|
return new Date(value).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("createdBy", {
|
||||||
|
header: "Created By",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
header: "",
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }: { row: { original: Exam } }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div
|
||||||
|
data-tip="Load exam"
|
||||||
|
className="cursor-pointer tooltip"
|
||||||
|
onClick={async () =>
|
||||||
|
await loadExam(row.original.module, row.original.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
|
</div>
|
||||||
|
{PERMISSIONS.examManagement.delete.includes(user.type) && (
|
||||||
|
<div
|
||||||
|
data-tip="Delete"
|
||||||
|
className="cursor-pointer tooltip"
|
||||||
|
onClick={() => deleteExam(row.original)}
|
||||||
|
>
|
||||||
|
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: parsedExams,
|
||||||
|
columns: defaultColumns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||||
|
<thead>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<th className="p-4 text-left" key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody className="px-2">
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<tr
|
||||||
|
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
|
||||||
|
key={row.id}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td className="px-4 py-2" key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,29 +14,31 @@ import {toast} from "react-toastify";
|
|||||||
import readXlsxFile from "read-excel-file";
|
import readXlsxFile from "read-excel-file";
|
||||||
import {useFilePicker} from "use-file-picker";
|
import {useFilePicker} from "use-file-picker";
|
||||||
import {getUserCorporate} from "@/utils/groups";
|
import {getUserCorporate} from "@/utils/groups";
|
||||||
import { isAgentUser, isCorporateUser } from "@/resources/user";
|
import {isAgentUser, isCorporateUser, USER_TYPE_LABELS} from "@/resources/user";
|
||||||
|
import {checkAccess} from "@/utils/permissions";
|
||||||
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<Group>();
|
const columnHelper = createColumnHelper<Group>();
|
||||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
||||||
|
|
||||||
const LinkedCorporate = ({userId, users, groups}: {userId: string, users: User[], groups: Group[]}) => {
|
const LinkedCorporate = ({userId, users, groups}: {userId: string; users: User[]; groups: Group[]}) => {
|
||||||
const [companyName, setCompanyName] = useState("");
|
const [companyName, setCompanyName] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const user = users.find((u) => u.id === userId)
|
const user = users.find((u) => u.id === userId);
|
||||||
if (!user) return setCompanyName("")
|
if (!user) return setCompanyName("");
|
||||||
|
|
||||||
if (isCorporateUser(user)) return setCompanyName(user.corporateInformation?.companyInformation?.name || user.name)
|
if (isCorporateUser(user)) return setCompanyName(user.corporateInformation?.companyInformation?.name || user.name);
|
||||||
if (isAgentUser(user)) return setCompanyName(user.agentInformation?.companyName || user.name)
|
if (isAgentUser(user)) return setCompanyName(user.agentInformation?.companyName || user.name);
|
||||||
|
|
||||||
const belongingGroups = groups.filter((x) => x.participants.includes(userId))
|
const belongingGroups = groups.filter((x) => x.participants.includes(userId));
|
||||||
const belongingGroupsAdmins = belongingGroups.map((x) => users.find((u) => u.id === x.admin)).filter((x) => !!x && isCorporateUser(x))
|
const belongingGroupsAdmins = belongingGroups.map((x) => users.find((u) => u.id === x.admin)).filter((x) => !!x && isCorporateUser(x));
|
||||||
|
|
||||||
if (belongingGroupsAdmins.length === 0) return setCompanyName("")
|
if (belongingGroupsAdmins.length === 0) return setCompanyName("");
|
||||||
|
|
||||||
const admin = (belongingGroupsAdmins[0] as CorporateUser)
|
const admin = belongingGroupsAdmins[0] as CorporateUser;
|
||||||
setCompanyName(admin.corporateInformation?.companyInformation.name || admin.name)
|
setCompanyName(admin.corporateInformation?.companyInformation.name || admin.name);
|
||||||
}, [userId, users, groups]);
|
}, [userId, users, groups]);
|
||||||
|
|
||||||
return isLoading ? <span className="animate-pulse">Loading...</span> : <>{companyName}</>;
|
return isLoading ? <span className="animate-pulse">Loading...</span> : <>{companyName}</>;
|
||||||
@@ -107,7 +109,7 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
|||||||
const submit = () => {
|
const submit = () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
if (name !== group?.name && (name === "Students" || name === "Teachers")) {
|
if (name !== group?.name && (name?.trim() === "Students" || name?.trim() === "Teachers" || name?.trim() === "Corporate")) {
|
||||||
toast.error("That group name is reserved and cannot be used, please enter another one.");
|
toast.error("That group name is reserved and cannot be used, please enter another one.");
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
@@ -152,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
|
||||||
@@ -194,16 +202,20 @@ const filterTypes = ["corporate", "teacher", "mastercorporate"];
|
|||||||
export default function GroupList({user}: {user: User}) {
|
export default function GroupList({user}: {user: User}) {
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [editingGroup, setEditingGroup] = useState<Group>();
|
const [editingGroup, setEditingGroup] = useState<Group>();
|
||||||
const [filterByUser, setFilterByUser] = useState(false);
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const {groups: corporateGroups} = useGroups({
|
||||||
if (user && (['corporate', 'teacher', 'mastercorporate'].includes(user.type))) {
|
admin: user && filterTypes.includes(user?.type) ? user.id : undefined,
|
||||||
setFilterByUser(true);
|
userType: user?.type,
|
||||||
}
|
adminAdmins: user?.id,
|
||||||
}, [user]);
|
});
|
||||||
|
|
||||||
const deleteGroup = (group: Group) => {
|
const deleteGroup = (group: Group) => {
|
||||||
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
|
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
|
||||||
@@ -227,7 +239,7 @@ export default function GroupList({user}: {user: User}) {
|
|||||||
columnHelper.accessor("admin", {
|
columnHelper.accessor("admin", {
|
||||||
header: "Admin",
|
header: "Admin",
|
||||||
cell: (info) => (
|
cell: (info) => (
|
||||||
<div className="tooltip" data-tip={capitalize(users.find((x) => x.id === info.getValue())?.type)}>
|
<div className="tooltip" data-tip={USER_TYPE_LABELS[users.find((x) => x.id === info.getValue())?.type || "student"]}>
|
||||||
{users.find((x) => x.id === info.getValue())?.name}
|
{users.find((x) => x.id === info.getValue())?.name}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -250,14 +262,14 @@ export default function GroupList({user}: {user: User}) {
|
|||||||
cell: ({row}: {row: {original: Group}}) => {
|
cell: ({row}: {row: {original: Group}}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{user && (user.type === "developer" || user.type === "admin" || user.id === row.original.admin) && (
|
{user && (checkAccess(user, ["developer", "admin"]) || user.id === row.original.admin) && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{(!row.original.disableEditing || ["developer", "admin"].includes(user.type)) && (
|
{(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "editGroup") && (
|
||||||
<div data-tip="Edit" className="tooltip cursor-pointer" onClick={() => setEditingGroup(row.original)}>
|
<div data-tip="Edit" className="tooltip cursor-pointer" onClick={() => setEditingGroup(row.original)}>
|
||||||
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(!row.original.disableEditing || ["developer", "admin"].includes(user.type)) && (
|
{(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "deleteGroup") && (
|
||||||
<div data-tip="Delete" className="tooltip cursor-pointer" onClick={() => deleteGroup(row.original)}>
|
<div data-tip="Delete" className="tooltip cursor-pointer" onClick={() => deleteGroup(row.original)}>
|
||||||
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
|
||||||
</div>
|
</div>
|
||||||
@@ -290,13 +302,14 @@ export default function GroupList({user}: {user: User}) {
|
|||||||
user={user}
|
user={user}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
users={
|
users={
|
||||||
user?.type === "corporate" || user?.type === "teacher"
|
checkAccess(user, ["corporate", "teacher", "mastercorporate"])
|
||||||
? users.filter(
|
? users.filter(
|
||||||
(u) =>
|
(u) =>
|
||||||
groups
|
groups
|
||||||
.filter((g) => g.admin === user.id)
|
.filter((g) => g.admin === user.id)
|
||||||
.flatMap((g) => g.participants)
|
.flatMap((g) => g.participants)
|
||||||
.includes(u.id) || groups.flatMap((g) => g.participants).includes(u.id),
|
.includes(u.id) ||
|
||||||
|
(user?.type === "teacher" ? corporateGroups : groups).flatMap((g) => g.participants).includes(u.id),
|
||||||
)
|
)
|
||||||
: users
|
: users
|
||||||
}
|
}
|
||||||
@@ -327,11 +340,13 @@ export default function GroupList({user}: {user: User}) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<button
|
{checkAccess(user, ["teacher", "corporate", "mastercorporate", "admin", "developer"], permissions, "createGroup") && (
|
||||||
onClick={() => setIsCreating(true)}
|
<button
|
||||||
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out">
|
onClick={() => setIsCreating(true)}
|
||||||
New Group
|
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out">
|
||||||
</button>
|
New Group
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
import { User } from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import { Tab } from "@headlessui/react";
|
import {Tab, TabGroup, TabList, TabPanel, TabPanels} from "@headlessui/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import CodeList from "./CodeList";
|
import CodeList from "./CodeList";
|
||||||
import DiscountList from "./DiscountList";
|
import DiscountList from "./DiscountList";
|
||||||
@@ -7,132 +7,118 @@ import ExamList from "./ExamList";
|
|||||||
import GroupList from "./GroupList";
|
import GroupList from "./GroupList";
|
||||||
import PackageList from "./PackageList";
|
import PackageList from "./PackageList";
|
||||||
import UserList from "./UserList";
|
import UserList from "./UserList";
|
||||||
|
import {checkAccess} from "@/utils/permissions";
|
||||||
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
|
||||||
export default function Lists({ user }: { user: User }) {
|
export default function Lists({user}: {user: User}) {
|
||||||
return (
|
const {permissions} = usePermissions(user?.id || "");
|
||||||
<Tab.Group>
|
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-mti-purple-ultralight/40 p-1">
|
return (
|
||||||
<Tab
|
<TabGroup>
|
||||||
className={({ selected }) =>
|
<TabList className="flex space-x-1 rounded-xl bg-mti-purple-ultralight/40 p-1">
|
||||||
clsx(
|
<Tab
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
|
className={({selected}) =>
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
|
clsx(
|
||||||
"transition duration-300 ease-in-out",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
|
||||||
selected
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
|
||||||
? "bg-white shadow"
|
"transition duration-300 ease-in-out",
|
||||||
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
|
||||||
)
|
)
|
||||||
}
|
}>
|
||||||
>
|
User List
|
||||||
User List
|
</Tab>
|
||||||
</Tab>
|
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate", "teacher"]) && (
|
||||||
{user?.type === "developer" && (
|
<Tab
|
||||||
<Tab
|
className={({selected}) =>
|
||||||
className={({ selected }) =>
|
clsx(
|
||||||
clsx(
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
|
"transition duration-300 ease-in-out",
|
||||||
"transition duration-300 ease-in-out",
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
|
||||||
selected
|
)
|
||||||
? "bg-white shadow"
|
}>
|
||||||
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
|
Exam List
|
||||||
)
|
</Tab>
|
||||||
}
|
)}
|
||||||
>
|
<Tab
|
||||||
Exam List
|
className={({selected}) =>
|
||||||
</Tab>
|
clsx(
|
||||||
)}
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
|
||||||
<Tab
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
|
||||||
className={({ selected }) =>
|
"transition duration-300 ease-in-out",
|
||||||
clsx(
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
|
)
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
|
}>
|
||||||
"transition duration-300 ease-in-out",
|
Group List
|
||||||
selected
|
</Tab>
|
||||||
? "bg-white shadow"
|
{checkAccess(user, ["developer", "admin", "corporate"]) && (
|
||||||
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
|
<Tab
|
||||||
)
|
className={({selected}) =>
|
||||||
}
|
clsx(
|
||||||
>
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
|
||||||
Group List
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
|
||||||
</Tab>
|
"transition duration-300 ease-in-out",
|
||||||
{user && ["developer", "admin", "corporate"].includes(user.type) && (
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
|
||||||
<Tab
|
)
|
||||||
className={({ selected }) =>
|
}>
|
||||||
clsx(
|
Code List
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
|
</Tab>
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
|
)}
|
||||||
"transition duration-300 ease-in-out",
|
{checkAccess(user, ["developer", "admin"]) && (
|
||||||
selected
|
<Tab
|
||||||
? "bg-white shadow"
|
className={({selected}) =>
|
||||||
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
|
clsx(
|
||||||
)
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
|
||||||
}
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
|
||||||
>
|
"transition duration-300 ease-in-out",
|
||||||
Code List
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
|
||||||
</Tab>
|
)
|
||||||
)}
|
}>
|
||||||
{user && ["developer", "admin"].includes(user.type) && (
|
Package List
|
||||||
<Tab
|
</Tab>
|
||||||
className={({ selected }) =>
|
)}
|
||||||
clsx(
|
{checkAccess(user, ["developer", "admin"]) && (
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
|
<Tab
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
|
className={({selected}) =>
|
||||||
"transition duration-300 ease-in-out",
|
clsx(
|
||||||
selected
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
|
||||||
? "bg-white shadow"
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
|
||||||
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
|
"transition duration-300 ease-in-out",
|
||||||
)
|
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
|
||||||
}
|
)
|
||||||
>
|
}>
|
||||||
Package List
|
Discount List
|
||||||
</Tab>
|
</Tab>
|
||||||
)}
|
)}
|
||||||
{user && ["developer", "admin"].includes(user.type) && (
|
</TabList>
|
||||||
<Tab
|
<TabPanels className="mt-2">
|
||||||
className={({ selected }) =>
|
<TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||||
clsx(
|
<UserList user={user} />
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
|
</TabPanel>
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
|
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate", "teacher"]) && (
|
||||||
"transition duration-300 ease-in-out",
|
<TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||||
selected
|
<ExamList user={user} />
|
||||||
? "bg-white shadow"
|
</TabPanel>
|
||||||
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
|
)}
|
||||||
)
|
<TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||||
}
|
<GroupList user={user} />
|
||||||
>
|
</TabPanel>
|
||||||
Discount List
|
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "viewCodes") && (
|
||||||
</Tab>
|
<TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||||
)}
|
<CodeList user={user} />
|
||||||
</Tab.List>
|
</TabPanel>
|
||||||
<Tab.Panels className="mt-2">
|
)}
|
||||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
{checkAccess(user, ["developer", "admin"]) && (
|
||||||
<UserList user={user} />
|
<TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||||
</Tab.Panel>
|
<PackageList user={user} />
|
||||||
{user?.type === "developer" && (
|
</TabPanel>
|
||||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
)}
|
||||||
<ExamList user={user} />
|
{checkAccess(user, ["developer", "admin"]) && (
|
||||||
</Tab.Panel>
|
<TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
||||||
)}
|
<DiscountList user={user} />
|
||||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
</TabPanel>
|
||||||
<GroupList user={user} />
|
)}
|
||||||
</Tab.Panel>
|
</TabPanels>
|
||||||
{user && ["developer", "admin", "corporate"].includes(user.type) && (
|
</TabGroup>
|
||||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
);
|
||||||
<CodeList user={user} />
|
|
||||||
</Tab.Panel>
|
|
||||||
)}
|
|
||||||
{user && ["developer", "admin"].includes(user.type) && (
|
|
||||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
|
||||||
<PackageList user={user} />
|
|
||||||
</Tab.Panel>
|
|
||||||
)}
|
|
||||||
{user && ["developer", "admin"].includes(user.type) && (
|
|
||||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
|
|
||||||
<DiscountList user={user} />
|
|
||||||
</Tab.Panel>
|
|
||||||
)}
|
|
||||||
</Tab.Panels>
|
|
||||||
</Tab.Group>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -23,6 +23,7 @@ import {toast, ToastContainer} from "react-toastify";
|
|||||||
import {v4 as uuidv4} from "uuid";
|
import {v4 as uuidv4} from "uuid";
|
||||||
import useSessions from "@/hooks/useSessions";
|
import useSessions from "@/hooks/useSessions";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
page: "exams" | "exercises";
|
page: "exams" | "exercises";
|
||||||
@@ -54,6 +55,7 @@ export default function ExamPage({page}: Props) {
|
|||||||
const {showSolutions, setShowSolutions} = useExamStore((state) => state);
|
const {showSolutions, setShowSolutions} = useExamStore((state) => state);
|
||||||
const {selectedModules, setSelectedModules} = useExamStore((state) => state);
|
const {selectedModules, setSelectedModules} = useExamStore((state) => state);
|
||||||
const {inactivity, setInactivity} = useExamStore((state) => state);
|
const {inactivity, setInactivity} = useExamStore((state) => state);
|
||||||
|
const {bgColor, setBgColor} = useExamStore((state) => state);
|
||||||
|
|
||||||
const {user} = useUser({redirectTo: "/login"});
|
const {user} = useUser({redirectTo: "/login"});
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -257,6 +259,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} : {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -279,6 +282,13 @@ export default function ExamPage({page}: Props) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [statsAwaitingEvaluation]);
|
}, [statsAwaitingEvaluation]);
|
||||||
|
|
||||||
|
useEffect(()=> {
|
||||||
|
|
||||||
|
if(exam && exam.module === "level" && exam.parts[0].intro && !showSolutions) {
|
||||||
|
setBgColor("bg-ielts-level-light");
|
||||||
|
}
|
||||||
|
}, [exam, showSolutions, setBgColor])
|
||||||
|
|
||||||
const checkIfStatsHaveBeenEvaluated = (ids: string[]) => {
|
const checkIfStatsHaveBeenEvaluated = (ids: string[]) => {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -459,6 +469,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);
|
||||||
@@ -499,6 +522,7 @@ export default function ExamPage({page}: Props) {
|
|||||||
{user && (
|
{user && (
|
||||||
<Layout
|
<Layout
|
||||||
user={user}
|
user={user}
|
||||||
|
bgColor={bgColor}
|
||||||
className="justify-between"
|
className="justify-between"
|
||||||
focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length}
|
focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length}
|
||||||
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}>
|
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}>
|
||||||
|
|||||||
@@ -230,7 +230,11 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const LevelGeneration = () => {
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LevelGeneration = ({ id } : Props) => {
|
||||||
const [generatedExam, setGeneratedExam] = useState<LevelExam>();
|
const [generatedExam, setGeneratedExam] = useState<LevelExam>();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [resultingExam, setResultingExam] = useState<LevelExam>();
|
const [resultingExam, setResultingExam] = useState<LevelExam>();
|
||||||
@@ -296,7 +300,7 @@ const LevelGeneration = () => {
|
|||||||
module: "level",
|
module: "level",
|
||||||
difficulty,
|
difficulty,
|
||||||
variant: "full",
|
variant: "full",
|
||||||
isDiagnostic: true,
|
isDiagnostic: false,
|
||||||
parts: parts
|
parts: parts
|
||||||
.map((part, index) => {
|
.map((part, index) => {
|
||||||
const currentExercise = result.data.exercises[`exercise_${index + 1}`] as any;
|
const currentExercise = result.data.exercises[`exercise_${index + 1}`] as any;
|
||||||
@@ -420,10 +424,16 @@ const LevelGeneration = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(!id) {
|
||||||
|
toast.error("Please insert a title before submitting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const exam = {
|
const exam = {
|
||||||
...generatedExam,
|
...generatedExam,
|
||||||
|
id,
|
||||||
parts: generatedExam.parts.map((p, i) => ({...p, exercises: parts[i].part!.exercises})),
|
parts: generatedExam.parts.map((p, i) => ({...p, exercises: parts[i].part!.exercises})),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -228,7 +228,11 @@ interface ListeningPart {
|
|||||||
| string;
|
| string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ListeningGeneration = () => {
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListeningGeneration = ({ id } : Props) => {
|
||||||
const [part1, setPart1] = useState<ListeningPart>();
|
const [part1, setPart1] = useState<ListeningPart>();
|
||||||
const [part2, setPart2] = useState<ListeningPart>();
|
const [part2, setPart2] = useState<ListeningPart>();
|
||||||
const [part3, setPart3] = useState<ListeningPart>();
|
const [part3, setPart3] = useState<ListeningPart>();
|
||||||
@@ -258,11 +262,16 @@ const ListeningGeneration = () => {
|
|||||||
console.log({parts});
|
console.log({parts});
|
||||||
if (parts.length === 0) return toast.error("Please generate at least one section!");
|
if (parts.length === 0) return toast.error("Please generate at least one section!");
|
||||||
|
|
||||||
|
if(!id) {
|
||||||
|
toast.error("Please insert a title before submitting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post(`/api/exam/listening/generate/listening`, {
|
.post(`/api/exam/listening/generate/listening`, {
|
||||||
id: generate({minLength: 4, maxLength: 8, min: 3, max: 5, join: " ", formatter: capitalize}),
|
id,
|
||||||
parts,
|
parts,
|
||||||
minTimer,
|
minTimer,
|
||||||
difficulty,
|
difficulty,
|
||||||
|
|||||||
@@ -258,7 +258,11 @@ const PartTab = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ReadingGeneration = () => {
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReadingGeneration = ({ id } : Props) => {
|
||||||
const [part1, setPart1] = useState<ReadingPart>();
|
const [part1, setPart1] = useState<ReadingPart>();
|
||||||
const [part2, setPart2] = useState<ReadingPart>();
|
const [part2, setPart2] = useState<ReadingPart>();
|
||||||
const [part3, setPart3] = useState<ReadingPart>();
|
const [part3, setPart3] = useState<ReadingPart>();
|
||||||
@@ -300,13 +304,18 @@ const ReadingGeneration = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(!id) {
|
||||||
|
toast.error("Please insert a title before submitting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const exam: ReadingExam = {
|
const exam: ReadingExam = {
|
||||||
parts,
|
parts,
|
||||||
isDiagnostic: false,
|
isDiagnostic: false,
|
||||||
minTimer,
|
minTimer,
|
||||||
module: "reading",
|
module: "reading",
|
||||||
id: generate({minLength: 4, maxLength: 8, min: 3, max: 5, join: " ", formatter: capitalize}),
|
id,
|
||||||
type: "academic",
|
type: "academic",
|
||||||
variant: parts.length === 3 ? "full" : "partial",
|
variant: parts.length === 3 ? "full" : "partial",
|
||||||
difficulty,
|
difficulty,
|
||||||
@@ -328,7 +337,7 @@ const ReadingGeneration = () => {
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
toast.error("Something went wrong while generating, please try again later.");
|
toast.error(error.response.data.error || "Something went wrong while generating, please try again later.");
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -221,7 +221,11 @@ interface SpeakingPart {
|
|||||||
avatar?: (typeof AVATARS)[number];
|
avatar?: (typeof AVATARS)[number];
|
||||||
}
|
}
|
||||||
|
|
||||||
const SpeakingGeneration = () => {
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SpeakingGeneration = ({ id } : Props) => {
|
||||||
const [part1, setPart1] = useState<SpeakingPart>();
|
const [part1, setPart1] = useState<SpeakingPart>();
|
||||||
const [part2, setPart2] = useState<SpeakingPart>();
|
const [part2, setPart2] = useState<SpeakingPart>();
|
||||||
const [part3, setPart3] = useState<SpeakingPart>();
|
const [part3, setPart3] = useState<SpeakingPart>();
|
||||||
@@ -243,6 +247,11 @@ const SpeakingGeneration = () => {
|
|||||||
const submitExam = () => {
|
const submitExam = () => {
|
||||||
if (!part1?.result && !part2?.result && !part3?.result) return toast.error("Please generate at least one task!");
|
if (!part1?.result && !part2?.result && !part3?.result) return toast.error("Please generate at least one task!");
|
||||||
|
|
||||||
|
if(!id) {
|
||||||
|
toast.error("Please insert a title before submitting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const genders = [part1?.gender, part2?.gender, part3?.gender].filter((x) => !!x);
|
const genders = [part1?.gender, part2?.gender, part3?.gender].filter((x) => !!x);
|
||||||
@@ -256,7 +265,7 @@ const SpeakingGeneration = () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const exam: SpeakingExam = {
|
const exam: SpeakingExam = {
|
||||||
id: generate({minLength: 4, maxLength: 8, min: 3, max: 5, join: " ", formatter: capitalize}),
|
id,
|
||||||
isDiagnostic: false,
|
isDiagnostic: false,
|
||||||
exercises: exercises as (SpeakingExercise | InteractiveSpeakingExercise)[],
|
exercises: exercises as (SpeakingExercise | InteractiveSpeakingExercise)[],
|
||||||
minTimer,
|
minTimer,
|
||||||
|
|||||||
@@ -75,7 +75,11 @@ const TaskTab = ({task, index, difficulty, setTask}: {task?: string; difficulty:
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const WritingGeneration = () => {
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WritingGeneration = ({ id } : Props) => {
|
||||||
const [task1, setTask1] = useState<string>();
|
const [task1, setTask1] = useState<string>();
|
||||||
const [task2, setTask2] = useState<string>();
|
const [task2, setTask2] = useState<string>();
|
||||||
const [minTimer, setMinTimer] = useState(60);
|
const [minTimer, setMinTimer] = useState(60);
|
||||||
@@ -116,6 +120,11 @@ const WritingGeneration = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(!id) {
|
||||||
|
toast.error("Please insert a title before submitting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const exercise1 = task1
|
const exercise1 = task1
|
||||||
? ({
|
? ({
|
||||||
id: v4(),
|
id: v4(),
|
||||||
@@ -152,7 +161,7 @@ const WritingGeneration = () => {
|
|||||||
minTimer,
|
minTimer,
|
||||||
module: "writing",
|
module: "writing",
|
||||||
exercises: [...(exercise1 ? [exercise1] : []), ...(exercise2 ? [exercise2] : [])],
|
exercises: [...(exercise1 ? [exercise1] : []), ...(exercise2 ? [exercise2] : [])],
|
||||||
id: generate({minLength: 4, maxLength: 8, min: 3, max: 5, join: " ", formatter: capitalize}),
|
id,
|
||||||
variant: exercise1 && exercise2 ? "full" : "partial",
|
variant: exercise1 && exercise2 ? "full" : "partial",
|
||||||
difficulty,
|
difficulty,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
442
src/pages/api/assignments/[id]/[export]/excel.ts
Normal file
442
src/pages/api/assignments/[id]/[export]/excel.ts
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { app, storage } from "@/firebase";
|
||||||
|
import {
|
||||||
|
getFirestore,
|
||||||
|
doc,
|
||||||
|
getDoc,
|
||||||
|
updateDoc,
|
||||||
|
getDocs,
|
||||||
|
query,
|
||||||
|
collection,
|
||||||
|
where,
|
||||||
|
documentId,
|
||||||
|
} from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
|
||||||
|
import { CorporateUser, MasterCorporateUser } from "@/interfaces/user";
|
||||||
|
import { User } from "@/interfaces/user";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import moment from "moment-timezone";
|
||||||
|
import ExcelJS from "exceljs";
|
||||||
|
import { getStudentGroupsForUsersWithoutAdmin } from "@/utils/groups.be";
|
||||||
|
import { getSpecificUsers, getUser } from "@/utils/users.be";
|
||||||
|
import { getUserName } from "@/utils/users";
|
||||||
|
interface GroupScoreSummaryHelper {
|
||||||
|
score: [number, number];
|
||||||
|
label: string;
|
||||||
|
sessions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssignmentData {
|
||||||
|
assigner: string;
|
||||||
|
assignees: string[];
|
||||||
|
results: any;
|
||||||
|
exams: { module: Module }[];
|
||||||
|
startDate: string;
|
||||||
|
excel: {
|
||||||
|
path: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
// if (req.method === "GET") return get(req, res);
|
||||||
|
if (req.method === "POST") return await post(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logWorksheetData(worksheet: any) {
|
||||||
|
worksheet.eachRow((row: any, rowNumber: number) => {
|
||||||
|
console.log(`Row ${rowNumber}:`);
|
||||||
|
row.eachCell((cell: any, colNumber: number) => {
|
||||||
|
console.log(` Cell ${colNumber}: ${cell.value}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function commonExcel({
|
||||||
|
data,
|
||||||
|
userName,
|
||||||
|
users,
|
||||||
|
sectionName,
|
||||||
|
customTable,
|
||||||
|
customTableHeaders,
|
||||||
|
renderCustomTableData,
|
||||||
|
}: {
|
||||||
|
data: AssignmentData;
|
||||||
|
userName: string;
|
||||||
|
users: User[];
|
||||||
|
sectionName: string;
|
||||||
|
customTable: string[][];
|
||||||
|
customTableHeaders: string[];
|
||||||
|
renderCustomTableData: (data: any) => string[];
|
||||||
|
}) {
|
||||||
|
const allStats = data.results.flatMap((r: any) => r.stats);
|
||||||
|
|
||||||
|
const uniqueExercises = [...new Set(allStats.map((s: any) => s.exercise))];
|
||||||
|
|
||||||
|
const assigneesData = data.assignees
|
||||||
|
.map((assignee: string) => {
|
||||||
|
const userStats = allStats.filter((s: any) => s.user === assignee);
|
||||||
|
const dates = userStats.map((s: any) => moment(s.date));
|
||||||
|
return {
|
||||||
|
userId: assignee,
|
||||||
|
user: users.find((u) => u.id === assignee),
|
||||||
|
...userStats.reduce(
|
||||||
|
(acc: any, curr: any) => {
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
correct: acc.correct + curr.score.correct,
|
||||||
|
missing: acc.missing + curr.score.missing,
|
||||||
|
total: acc.total + curr.score.total,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ correct: 0, missing: 0, total: 0 }
|
||||||
|
),
|
||||||
|
firstDate: moment.min(...dates),
|
||||||
|
lastDate: moment.max(...dates),
|
||||||
|
stats: userStats,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.correct - a.correct);
|
||||||
|
|
||||||
|
const results = assigneesData.map((r: any) => r.correct);
|
||||||
|
const highestScore = Math.max(...results);
|
||||||
|
const lowestScore = Math.min(...results);
|
||||||
|
const averageScore = results.reduce((a, b) => a + b, 0) / results.length;
|
||||||
|
const firstDate = moment.min(assigneesData.map((r: any) => r.firstDate));
|
||||||
|
const lastDate = moment.max(assigneesData.map((r: any) => r.lastDate));
|
||||||
|
|
||||||
|
const firstSectionData = [
|
||||||
|
{
|
||||||
|
label: sectionName,
|
||||||
|
value: userName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Report Download date :",
|
||||||
|
value: moment().format("DD/MM/YYYY"),
|
||||||
|
},
|
||||||
|
{ label: "Test Information :", value: data.name },
|
||||||
|
{
|
||||||
|
label: "Date of Test :",
|
||||||
|
value: moment(data.startDate).format("DD/MM/YYYY"),
|
||||||
|
},
|
||||||
|
{ label: "Number of Candidates :", value: data.assignees.length },
|
||||||
|
{ label: "Highest score :", value: highestScore },
|
||||||
|
{ label: "Lowest score :", value: lowestScore },
|
||||||
|
{ label: "Average score :", value: averageScore },
|
||||||
|
{ label: "", value: "" },
|
||||||
|
{
|
||||||
|
label: "Date and time of First submission :",
|
||||||
|
value: firstDate.format("DD/MM/YYYY"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Date and time of Last submission :",
|
||||||
|
value: lastDate.format("DD/MM/YYYY"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create a new workbook and add a worksheet
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const worksheet = workbook.addWorksheet("Report Data");
|
||||||
|
|
||||||
|
// Populate the worksheet with the data
|
||||||
|
firstSectionData.forEach(({ label, value }, index) => {
|
||||||
|
worksheet.getCell(`A${index + 1}`).value = label; // First column (labels)
|
||||||
|
worksheet.getCell(`B${index + 1}`).value = value; // Second column (values)
|
||||||
|
});
|
||||||
|
|
||||||
|
// added empty arrays to force row spacings
|
||||||
|
const customTableAndLine = [[],...customTable, []];
|
||||||
|
customTableAndLine.forEach((row: string[], index) => {
|
||||||
|
worksheet.addRow(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Define the static part of the headers (before "Test Sections")
|
||||||
|
const staticHeaders = [
|
||||||
|
"Sr N",
|
||||||
|
"Candidate ID",
|
||||||
|
"First and Last Name",
|
||||||
|
"Passport/ID",
|
||||||
|
"Email ID",
|
||||||
|
"Gender",
|
||||||
|
...customTableHeaders,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Define additional headers after "Test Sections"
|
||||||
|
const additionalHeaders = ["Time Spent", "Date", "Score"];
|
||||||
|
|
||||||
|
// Calculate the dynamic columns based on the testSectionsArray
|
||||||
|
const testSectionHeaders = uniqueExercises.map(
|
||||||
|
(section, index) => `Part ${index + 1}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const tableColumnHeadersFirstPart = [
|
||||||
|
...staticHeaders,
|
||||||
|
...uniqueExercises.map((a) => "Test Sections"),
|
||||||
|
];
|
||||||
|
// Add the main header row, merging static columns and "Test Sections"
|
||||||
|
const tableColumnHeaders = [
|
||||||
|
...tableColumnHeadersFirstPart,
|
||||||
|
...additionalHeaders,
|
||||||
|
];
|
||||||
|
worksheet.addRow(tableColumnHeaders);
|
||||||
|
|
||||||
|
// 1 headers rows
|
||||||
|
const startIndexTable = firstSectionData.length + customTableAndLine.length + 1;
|
||||||
|
|
||||||
|
// // Merge "Test Sections" over dynamic number of columns
|
||||||
|
// const tableColumns = staticHeaders.length + numberOfTestSections;
|
||||||
|
|
||||||
|
// K10:M12 = 10,11,12,13
|
||||||
|
// horizontally group Test Sections
|
||||||
|
worksheet.mergeCells(
|
||||||
|
startIndexTable,
|
||||||
|
staticHeaders.length + 1,
|
||||||
|
startIndexTable,
|
||||||
|
tableColumnHeadersFirstPart.length
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add the dynamic second and third header rows for test sections and sub-columns
|
||||||
|
worksheet.addRow([
|
||||||
|
...Array(staticHeaders.length).fill(""),
|
||||||
|
...testSectionHeaders,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
]);
|
||||||
|
worksheet.addRow([
|
||||||
|
...Array(staticHeaders.length).fill(""),
|
||||||
|
...uniqueExercises.map(() => "Grammar & Vocabulary"),
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
]);
|
||||||
|
worksheet.addRow([
|
||||||
|
...Array(staticHeaders.length).fill(""),
|
||||||
|
...uniqueExercises.map(
|
||||||
|
(exercise) => allStats.find((s: any) => s.exercise === exercise).type
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// vertically group based on the part, exercise and type
|
||||||
|
staticHeaders.forEach((header, index) => {
|
||||||
|
worksheet.mergeCells(startIndexTable, index + 1, startIndexTable + 3, index + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
assigneesData.forEach((data, index) => {
|
||||||
|
worksheet.addRow([
|
||||||
|
index + 1,
|
||||||
|
data.userId,
|
||||||
|
data.user.name,
|
||||||
|
data.user.demographicInformation?.passportId,
|
||||||
|
data.user.email,
|
||||||
|
data.user.demographicInformation?.gender,
|
||||||
|
...renderCustomTableData(data),
|
||||||
|
...uniqueExercises.map((exercise) => {
|
||||||
|
const score = data.stats.find(
|
||||||
|
(s: any) => s.exercise === exercise && s.user === data.userId
|
||||||
|
).score;
|
||||||
|
return `${score.correct}/${score.total}`;
|
||||||
|
}),
|
||||||
|
`${Math.ceil(
|
||||||
|
data.stats.reduce((acc: number, curr: any) => acc + curr.timeSpent, 0) /
|
||||||
|
60
|
||||||
|
)} minutes`,
|
||||||
|
data.lastDate.format("DD/MM/YYYY HH:mm"),
|
||||||
|
data.correct,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
worksheet.addRow([""]);
|
||||||
|
worksheet.addRow([""]);
|
||||||
|
|
||||||
|
for (let i = 0; i < tableColumnHeaders.length; i++) {
|
||||||
|
worksheet.getColumn(i + 1).width = 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply styles to the headers
|
||||||
|
[startIndexTable].forEach((rowNumber) => {
|
||||||
|
worksheet.getRow(rowNumber).eachCell((cell) => {
|
||||||
|
if (cell.value) {
|
||||||
|
cell.fill = {
|
||||||
|
type: "pattern",
|
||||||
|
pattern: "solid",
|
||||||
|
fgColor: { argb: "FFBFBFBF" }, // Grey color for headers
|
||||||
|
};
|
||||||
|
cell.font = { bold: true };
|
||||||
|
cell.alignment = { vertical: "middle", horizontal: "center" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
worksheet.addRow(["Printed by: Confidential Information"]);
|
||||||
|
worksheet.addRow(["info@encoach.com"]);
|
||||||
|
|
||||||
|
// Convert workbook to Buffer (Node.js) or Blob (Browser)
|
||||||
|
return workbook.xlsx.writeBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function corporateAssignment(
|
||||||
|
user: CorporateUser,
|
||||||
|
data: AssignmentData,
|
||||||
|
users: User[]
|
||||||
|
) {
|
||||||
|
return commonExcel({
|
||||||
|
data,
|
||||||
|
userName: user.corporateInformation?.companyInformation?.name || "",
|
||||||
|
users,
|
||||||
|
sectionName: "Corporate Name :",
|
||||||
|
customTable: [],
|
||||||
|
customTableHeaders: [],
|
||||||
|
renderCustomTableData: () => [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mastercorporateAssignment(
|
||||||
|
user: MasterCorporateUser,
|
||||||
|
data: AssignmentData,
|
||||||
|
users: User[]
|
||||||
|
) {
|
||||||
|
const userGroups = await getStudentGroupsForUsersWithoutAdmin(
|
||||||
|
user.id,
|
||||||
|
data.assignees
|
||||||
|
);
|
||||||
|
const adminUsers = [...new Set(userGroups.map((g) => g.admin))];
|
||||||
|
|
||||||
|
const userGroupsParticipants = userGroups.flatMap((g) => g.participants);
|
||||||
|
const adminsData = await getSpecificUsers(adminUsers);
|
||||||
|
const companiesData = adminsData.map((user) => {
|
||||||
|
const name = getUserName(user);
|
||||||
|
const users = userGroupsParticipants
|
||||||
|
.filter((p) => data.assignees.includes(p));
|
||||||
|
|
||||||
|
const stats = data.results
|
||||||
|
.flatMap((r: any) => r.stats)
|
||||||
|
.filter((s: any) => users.includes(s.user));
|
||||||
|
const correct = stats.reduce((acc: number, s: any) => acc + s.score.correct, 0);
|
||||||
|
const total = stats.reduce(
|
||||||
|
(acc: number, curr: any) => acc + curr.score.total,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
correct,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const customTable = [
|
||||||
|
...companiesData,
|
||||||
|
{
|
||||||
|
name: "Total",
|
||||||
|
correct: companiesData.reduce((acc, curr) => acc + curr.correct, 0),
|
||||||
|
total: companiesData.reduce((acc, curr) => acc + curr.total, 0),
|
||||||
|
},
|
||||||
|
].map((c) => [c.name, `${c.correct}/${c.total}`])
|
||||||
|
|
||||||
|
const customTableHeaders = [{ name: "Corporate", helper: (data: any) => data.user.corporateName}];
|
||||||
|
return commonExcel({
|
||||||
|
data,
|
||||||
|
userName: user.corporateInformation?.companyInformation?.name || "",
|
||||||
|
users: users.map((u) => {
|
||||||
|
const userGroup = userGroups.find((g) => g.participants.includes(u.id));
|
||||||
|
const admin = adminsData.find((a) => a.id === userGroup?.admin);
|
||||||
|
return {
|
||||||
|
...u,
|
||||||
|
corporateName: getUserName(admin),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
sectionName: "Master Corporate Name :",
|
||||||
|
customTable: [['Corporate Summary'], ...customTable],
|
||||||
|
customTableHeaders: customTableHeaders.map((h) => h.name),
|
||||||
|
renderCustomTableData: (data) => customTableHeaders.map((h) => h.helper(data)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
// verify if it's a logged user that is trying to export
|
||||||
|
if (req.session.user) {
|
||||||
|
const { id } = req.query as { id: string };
|
||||||
|
|
||||||
|
const docSnap = await getDoc(doc(db, "assignments", id));
|
||||||
|
const data = docSnap.data() as AssignmentData;
|
||||||
|
if (!data) {
|
||||||
|
res.status(400).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if (
|
||||||
|
// data.excel &&
|
||||||
|
// data.excel.path &&
|
||||||
|
// data.excel.version === process.env.EXCEL_VERSION
|
||||||
|
// ) {
|
||||||
|
// // if it does, return the excel url
|
||||||
|
// const fileRef = ref(storage, data.excel.path);
|
||||||
|
// const url = await getDownloadURL(fileRef);
|
||||||
|
// res.status(200).end(url);
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
const docsSnap = await getDocs(
|
||||||
|
query(collection(db, "users"), where(documentId(), "in", data.assignees))
|
||||||
|
);
|
||||||
|
const users = docsSnap.docs.map((d) => ({
|
||||||
|
...d.data(),
|
||||||
|
id: d.id,
|
||||||
|
})) as User[];
|
||||||
|
|
||||||
|
const docUser = await getDoc(doc(db, "users", data.assigner));
|
||||||
|
if (docUser.exists()) {
|
||||||
|
// we'll need the user in order to get the user data (name, email, focus, etc);
|
||||||
|
const user = docUser.data() as User;
|
||||||
|
|
||||||
|
// generate the file ref for storage
|
||||||
|
const fileName = `${Date.now().toString()}.xlsx`;
|
||||||
|
const refName = `assignment_report/${fileName}`;
|
||||||
|
const fileRef = ref(storage, refName);
|
||||||
|
|
||||||
|
const getExcelFn = () => {
|
||||||
|
switch (user.type) {
|
||||||
|
case "teacher":
|
||||||
|
case "corporate":
|
||||||
|
return corporateAssignment(user as CorporateUser, data, users);
|
||||||
|
case "mastercorporate":
|
||||||
|
return mastercorporateAssignment(user as MasterCorporateUser, data, users);
|
||||||
|
default:
|
||||||
|
throw new Error("Invalid user type");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const buffer = await getExcelFn();
|
||||||
|
|
||||||
|
// upload the pdf to storage
|
||||||
|
const snapshot = await uploadBytes(fileRef, buffer, {
|
||||||
|
contentType:
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
});
|
||||||
|
|
||||||
|
// update the stats entries with the pdf url to prevent duplication
|
||||||
|
await updateDoc(docSnap.ref, {
|
||||||
|
excel: {
|
||||||
|
path: refName,
|
||||||
|
version: process.env.EXCEL_VERSION,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const url = await getDownloadURL(fileRef);
|
||||||
|
res.status(200).end(url);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(401).json({ message: "Unauthorized" });
|
||||||
|
}
|
||||||
40
src/pages/api/assignments/corporate/[id].ts
Normal file
40
src/pages/api/assignments/corporate/[id].ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
|
import {app} from "@/firebase";
|
||||||
|
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc} from "firebase/firestore";
|
||||||
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
import {uuidv4} from "@firebase/util";
|
||||||
|
import {Module} from "@/interfaces";
|
||||||
|
import {getExams} from "@/utils/exams.be";
|
||||||
|
import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
|
||||||
|
import {capitalize, flatten, uniqBy} from "lodash";
|
||||||
|
import {User} from "@/interfaces/user";
|
||||||
|
import moment from "moment";
|
||||||
|
import {sendEmail} from "@/email";
|
||||||
|
import {getAllAssignersByCorporate} from "@/utils/groups.be";
|
||||||
|
import {getAssignmentsByAssigners} from "@/utils/assignments.be";
|
||||||
|
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ok: false});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET") return GET(req, res);
|
||||||
|
|
||||||
|
res.status(404).json({ok: false});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const {id} = req.query as {id: string};
|
||||||
|
|
||||||
|
const assigners = await getAllAssignersByCorporate(id);
|
||||||
|
const assignments = await getAssignmentsByAssigners([...assigners, id]);
|
||||||
|
|
||||||
|
res.status(200).json(uniqBy(assignments, "id"));
|
||||||
|
}
|
||||||
34
src/pages/api/assignments/corporate/index.ts
Normal file
34
src/pages/api/assignments/corporate/index.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import { getAllAssignersByCorporate } from "@/utils/groups.be";
|
||||||
|
import { getAssignmentsByAssigners } from "@/utils/assignments.be";
|
||||||
|
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET") return await GET(req, res);
|
||||||
|
|
||||||
|
res.status(404).json({ ok: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const { ids } = req.query as { ids: string };
|
||||||
|
try {
|
||||||
|
const idsList = ids.split(",");
|
||||||
|
|
||||||
|
const assigners = await Promise.all(idsList.map(getAllAssignersByCorporate));
|
||||||
|
const assignmentList = [...assigners.flat(), ...idsList];
|
||||||
|
const assignments = await getAssignmentsByAssigners(assignmentList);
|
||||||
|
res.status(200).json(assignments);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,89 @@
|
|||||||
// 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 {getFirestore, setDoc, doc} from "firebase/firestore";
|
import {
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
getFirestore,
|
||||||
import {sessionOptions} from "@/lib/session";
|
setDoc,
|
||||||
import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
|
doc,
|
||||||
import {getExams} from "@/utils/exams.be";
|
runTransaction,
|
||||||
import {Module} from "@/interfaces";
|
collection,
|
||||||
|
query,
|
||||||
|
where,
|
||||||
|
getDocs,
|
||||||
|
} from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import { Exam, InstructorGender, Variant } from "@/interfaces/exam";
|
||||||
|
import { getExams } from "@/utils/exams.be";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
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 await GET(req, res);
|
if (req.method === "GET") return await GET(req, res);
|
||||||
if (req.method === "POST") return await POST(req, res);
|
if (req.method === "POST") return await POST(req, res);
|
||||||
|
|
||||||
res.status(404).json({ok: false});
|
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.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {module, avoidRepeated, variant, instructorGender} = req.query as {
|
const { module, avoidRepeated, variant, instructorGender } = req.query as {
|
||||||
module: Module;
|
module: Module;
|
||||||
avoidRepeated: string;
|
avoidRepeated: string;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
instructorGender?: InstructorGender;
|
instructorGender?: InstructorGender;
|
||||||
};
|
};
|
||||||
|
|
||||||
const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id, variant, instructorGender);
|
const exams: Exam[] = await getExams(
|
||||||
res.status(200).json(exams);
|
db,
|
||||||
|
module,
|
||||||
|
avoidRepeated,
|
||||||
|
req.session.user.id,
|
||||||
|
variant,
|
||||||
|
instructorGender
|
||||||
|
);
|
||||||
|
res.status(200).json(exams);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ok: false});
|
res.status(401).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.session.user.type !== "developer") {
|
if (req.session.user.type !== "developer") {
|
||||||
res.status(403).json({ok: false});
|
res.status(403).json({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const {module} = req.query as {module: string};
|
const { module } = req.query as { module: string };
|
||||||
|
|
||||||
const exam = {...req.body, module: module};
|
try {
|
||||||
await setDoc(doc(db, module, req.body.id), exam);
|
const exam = {
|
||||||
|
...req.body,
|
||||||
|
module: module,
|
||||||
|
createdBy: req.session.user.id,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await runTransaction(db, async (transaction) => {
|
||||||
|
const docRef = doc(db, module, req.body.id);
|
||||||
|
const docSnap = await transaction.get(docRef);
|
||||||
|
|
||||||
res.status(200).json(exam);
|
if (docSnap.exists()) {
|
||||||
|
throw new Error("Name already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
const newDocRef = doc(db, module, req.body.id);
|
||||||
|
transaction.set(newDocRef, exam);
|
||||||
|
});
|
||||||
|
res.status(200).json(exam);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Transaction failed: ", error);
|
||||||
|
res.status(500).json({ ok: false, error: (error as any).message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,118 +1,68 @@
|
|||||||
// 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, collection, getDocs, setDoc, doc, query, where} from "firebase/firestore";
|
||||||
getFirestore,
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
collection,
|
import {sessionOptions} from "@/lib/session";
|
||||||
getDocs,
|
import {Group} from "@/interfaces/user";
|
||||||
setDoc,
|
import {v4} from "uuid";
|
||||||
doc,
|
import {updateExpiryDateOnGroup, getGroupsForUser} from "@/utils/groups.be";
|
||||||
query,
|
import {uniqBy} from "lodash";
|
||||||
where,
|
|
||||||
} from "firebase/firestore";
|
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
|
||||||
import { sessionOptions } from "@/lib/session";
|
|
||||||
import { Group } from "@/interfaces/user";
|
|
||||||
import { v4 } from "uuid";
|
|
||||||
import { updateExpiryDateOnGroup } from "@/utils/groups.be";
|
|
||||||
|
|
||||||
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.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ ok: false });
|
res.status(401).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "GET") await get(req, res);
|
if (req.method === "GET") await get(req, res);
|
||||||
if (req.method === "POST") await post(req, res);
|
if (req.method === "POST") await post(req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getGroupsForUser = async (admin: string, participant: string) => {
|
|
||||||
try {
|
|
||||||
const queryConstraints = [
|
|
||||||
...(admin ? [where("admin", "==", admin)] : []),
|
|
||||||
...(participant
|
|
||||||
? [where("participants", "array-contains", participant)]
|
|
||||||
: []),
|
|
||||||
];
|
|
||||||
const snapshot = await getDocs(
|
|
||||||
queryConstraints.length > 0
|
|
||||||
? query(collection(db, "groups"), ...queryConstraints)
|
|
||||||
: collection(db, "groups")
|
|
||||||
);
|
|
||||||
const groups = snapshot.docs.map((doc) => ({
|
|
||||||
id: doc.id,
|
|
||||||
...doc.data(),
|
|
||||||
})) as Group[];
|
|
||||||
|
|
||||||
return groups;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const { admin, participant } = req.query as {
|
const {admin, participant} = req.query as {
|
||||||
admin: string;
|
admin: string;
|
||||||
participant: string;
|
participant: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (req.session?.user?.type === "mastercorporate") {
|
if (req.session?.user?.type === "mastercorporate") {
|
||||||
try {
|
try {
|
||||||
const masterCorporateGroups = await getGroupsForUser(admin, participant);
|
const masterCorporateGroups = await getGroupsForUser(admin, participant);
|
||||||
const corporatesFromMaster = masterCorporateGroups
|
const corporatesFromMaster = masterCorporateGroups.filter((g) => g.name.trim() === "Corporate").flatMap((g) => g.participants);
|
||||||
.filter((g) => g.name === "Corporate")
|
|
||||||
.flatMap((g) => g.participants);
|
|
||||||
|
|
||||||
if (corporatesFromMaster.length === 0) {
|
if (corporatesFromMaster.length === 0) return res.status(200).json(masterCorporateGroups);
|
||||||
res.status(200).json([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Promise.all(
|
|
||||||
corporatesFromMaster.map((c) => getGroupsForUser(c, participant))
|
|
||||||
)
|
|
||||||
.then((groups) => {
|
|
||||||
res.status(200).json([...masterCorporateGroups, ...groups.flat()]);
|
|
||||||
return;
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
res.status(500).json({ ok: false });
|
|
||||||
return;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
res.status(500).json({ ok: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
const groups = await Promise.all(corporatesFromMaster.map((c) => getGroupsForUser(c, participant)));
|
||||||
const groups = await getGroupsForUser(admin, participant);
|
return res.status(200).json([...masterCorporateGroups, ...uniqBy(groups.flat(), "id")]);
|
||||||
res.status(200).json(groups);
|
} catch (e) {
|
||||||
} catch (e) {
|
console.error(e);
|
||||||
console.error(e);
|
res.status(500).json({ok: false});
|
||||||
res.status(500).json({ ok: false });
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const groups = await getGroupsForUser(admin, participant);
|
||||||
|
res.status(200).json(groups);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ok: false});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const body = req.body as Group;
|
const body = req.body as Group;
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(body.participants.map(async (p) => await updateExpiryDateOnGroup(p, body.admin)));
|
||||||
body.participants.map(
|
|
||||||
async (p) => await updateExpiryDateOnGroup(p, body.admin)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await setDoc(doc(db, "groups", v4()), {
|
await setDoc(doc(db, "groups", v4()), {
|
||||||
name: body.name,
|
name: body.name,
|
||||||
admin: body.admin,
|
admin: body.admin,
|
||||||
participants: body.participants,
|
participants: body.participants,
|
||||||
});
|
});
|
||||||
res.status(200).json({ ok: true });
|
res.status(200).json({ok: true});
|
||||||
}
|
}
|
||||||
|
|||||||
134
src/pages/api/make_user.ts
Normal file
134
src/pages/api/make_user.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
|
import {app} from "@/firebase";
|
||||||
|
import {getFirestore, setDoc, doc, query, collection, where, getDocs, getDoc, deleteDoc, limit, updateDoc} from "firebase/firestore";
|
||||||
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
import {v4} from "uuid";
|
||||||
|
import {Group} from "@/interfaces/user";
|
||||||
|
import {createUserWithEmailAndPassword, getAuth} from "firebase/auth";
|
||||||
|
|
||||||
|
const DEFAULT_DESIRED_LEVELS = {
|
||||||
|
reading: 9,
|
||||||
|
listening: 9,
|
||||||
|
writing: 9,
|
||||||
|
speaking: 9,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_LEVELS = {
|
||||||
|
reading: 0,
|
||||||
|
listening: 0,
|
||||||
|
writing: 0,
|
||||||
|
speaking: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const auth = getAuth(app);
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "POST") return post(req, res);
|
||||||
|
|
||||||
|
return res.status(404).json({ok: false});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const maker = req.session.user;
|
||||||
|
if (!maker) {
|
||||||
|
return res.status(401).json({ok: false, reason: "You must be logged in to make user!"});
|
||||||
|
}
|
||||||
|
const {email, passport_id, type, groupName, expiryDate} = req.body as {
|
||||||
|
email: string;
|
||||||
|
passport_id: string;
|
||||||
|
type: string;
|
||||||
|
groupName: string;
|
||||||
|
expiryDate: null | Date;
|
||||||
|
};
|
||||||
|
// cleaning data
|
||||||
|
delete req.body.passport_id;
|
||||||
|
delete req.body.groupName;
|
||||||
|
delete req.body.expiryDate;
|
||||||
|
|
||||||
|
await createUserWithEmailAndPassword(auth, email.toLowerCase(), passport_id)
|
||||||
|
.then(async (userCredentials) => {
|
||||||
|
const userId = userCredentials.user.uid;
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
...req.body,
|
||||||
|
bio: "",
|
||||||
|
type: type,
|
||||||
|
focus: "academic",
|
||||||
|
status: "active",
|
||||||
|
desiredLevels: DEFAULT_DESIRED_LEVELS,
|
||||||
|
levels: DEFAULT_LEVELS,
|
||||||
|
isFirstLogin: false,
|
||||||
|
isVerified: true,
|
||||||
|
registrationDate: new Date(),
|
||||||
|
subscriptionExpirationDate: expiryDate || null,
|
||||||
|
};
|
||||||
|
await setDoc(doc(db, "users", userId), user);
|
||||||
|
if (type === "corporate") {
|
||||||
|
const defaultTeachersGroup: Group = {
|
||||||
|
admin: userId,
|
||||||
|
id: v4(),
|
||||||
|
name: "Teachers",
|
||||||
|
participants: [],
|
||||||
|
disableEditing: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultStudentsGroup: Group = {
|
||||||
|
admin: userId,
|
||||||
|
id: v4(),
|
||||||
|
name: "Students",
|
||||||
|
participants: [],
|
||||||
|
disableEditing: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultCorporateGroup: Group = {
|
||||||
|
admin: userId,
|
||||||
|
id: v4(),
|
||||||
|
name: "Corporate",
|
||||||
|
participants: [],
|
||||||
|
disableEditing: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
await setDoc(doc(db, "groups", defaultTeachersGroup.id), defaultTeachersGroup);
|
||||||
|
await setDoc(doc(db, "groups", defaultStudentsGroup.id), defaultStudentsGroup);
|
||||||
|
await setDoc(doc(db, "groups", defaultCorporateGroup.id), defaultCorporateGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof groupName === "string" && groupName.trim().length > 0) {
|
||||||
|
const q = query(collection(db, "groups"), where("admin", "==", maker.id), where("name", "==", groupName.trim()), limit(1));
|
||||||
|
const snapshot = await getDocs(q);
|
||||||
|
|
||||||
|
if (snapshot.empty) {
|
||||||
|
const values = {
|
||||||
|
id: v4(),
|
||||||
|
admin: maker.id,
|
||||||
|
name: groupName.trim(),
|
||||||
|
participants: [userId],
|
||||||
|
disableEditing: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await setDoc(doc(db, "groups", values.id), values);
|
||||||
|
} else {
|
||||||
|
const doc = snapshot.docs[0];
|
||||||
|
const participants: string[] = doc.get("participants");
|
||||||
|
|
||||||
|
if (!participants.includes(userId)) {
|
||||||
|
updateDoc(doc.ref, {
|
||||||
|
participants: [...participants, userId],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Returning - ${email}`);
|
||||||
|
return res.status(200).json({ok: true});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.log(`Failing - ${email}`);
|
||||||
|
console.log(error);
|
||||||
|
return res.status(401).json({error});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,30 +1,46 @@
|
|||||||
// 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 { getFirestore, doc, setDoc } from "firebase/firestore";
|
import {getFirestore, doc, setDoc, getDoc} from "firebase/firestore";
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
import {getPermissionDoc} from "@/utils/permissions.be";
|
||||||
|
|
||||||
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 === "PATCH") return patch(req, res);
|
if (req.method === "PATCH") return patch(req, res);
|
||||||
|
if (req.method === "GET") return get(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ok: false});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {id} = req.query as {id: string};
|
||||||
|
|
||||||
|
const permissionDoc = await getPermissionDoc(id);
|
||||||
|
return res.status(200).json({allowed: permissionDoc.users.includes(req.session.user.id)});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function patch(req: NextApiRequest, res: NextApiResponse) {
|
async function patch(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ ok: false });
|
res.status(401).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { id } = req.query as { id: string };
|
|
||||||
const { users } = req.body;
|
const {id} = req.query as {id: string};
|
||||||
try {
|
const {users} = req.body;
|
||||||
await setDoc(doc(db, "permissions", id), { users }, { merge: true });
|
|
||||||
return res.status(200).json({ ok: true });
|
try {
|
||||||
} catch (err) {
|
await setDoc(doc(db, "permissions", id), {users}, {merge: true});
|
||||||
console.error(err);
|
return res.status(200).json({ok: true});
|
||||||
return res.status(500).json({ ok: false });
|
} catch (err) {
|
||||||
}
|
console.error(err);
|
||||||
|
return res.status(500).json({ok: false});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
434
src/pages/api/stats/[id]/[export]/pdf.tsx
Normal file
434
src/pages/api/stats/[id]/[export]/pdf.tsx
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { app, storage } from "@/firebase";
|
||||||
|
import {
|
||||||
|
getFirestore,
|
||||||
|
doc,
|
||||||
|
getDoc,
|
||||||
|
updateDoc,
|
||||||
|
getDocs,
|
||||||
|
query,
|
||||||
|
collection,
|
||||||
|
where,
|
||||||
|
} from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import ReactPDF from "@react-pdf/renderer";
|
||||||
|
import TestReport from "@/exams/pdf/test.report";
|
||||||
|
import LevelTestReport from "@/exams/pdf/level.test.report";
|
||||||
|
import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
|
||||||
|
import {
|
||||||
|
DemographicInformation,
|
||||||
|
Stat,
|
||||||
|
StudentUser,
|
||||||
|
User,
|
||||||
|
} from "@/interfaces/user";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import { ModuleScore } from "@/interfaces/module.scores";
|
||||||
|
import { SkillExamDetails } from "@/exams/pdf/details/skill.exam";
|
||||||
|
import { calculateBandScore } from "@/utils/score";
|
||||||
|
import axios from "axios";
|
||||||
|
import { moduleLabels } from "@/utils/moduleUtils";
|
||||||
|
import {
|
||||||
|
generateQRCode,
|
||||||
|
getRadialProgressPNG,
|
||||||
|
streamToBuffer,
|
||||||
|
} from "@/utils/pdf";
|
||||||
|
import moment from "moment-timezone";
|
||||||
|
import { getCorporateNameForStudent } from "@/utils/groups.be";
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "GET") return get(req, res);
|
||||||
|
if (req.method === "POST") return post(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getExamSummary = (score: number) => {
|
||||||
|
if (score > 0.8) {
|
||||||
|
return "Scoring between 81% and 100% on the English exam demonstrates an outstanding level of proficiency in writing, speaking, listening, and reading. Mastery of key concepts is evident across all language domains, showcasing not only a high level of skill but also a dedication to excellence. Continuing to challenge oneself with advanced material in writing, speaking, listening, and reading will further refine the already impressive command of the English language.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score > 0.6) {
|
||||||
|
return "Scoring between 61% and 80% on the English exam, encompassing writing, speaking, listening, and reading, reflects a commendable level of proficiency in each domain. There's evidence of a solid grasp of key concepts, and effective application of skills. Room for refinement and deeper exploration in writing, speaking, listening, and reading remains, presenting an opportunity for further mastery.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score > 0.4) {
|
||||||
|
return "Scoring between 41% and 60% on the English exam across writing, speaking, listening, and reading demonstrates a moderate level of understanding in each domain. While there's a commendable grasp of key concepts, refining fundamental skills in writing, speaking, listening, and reading can lead to notable improvement. Consistent effort and targeted focus on weaker areas are recommended.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score > 0.2) {
|
||||||
|
return "Scoring between 21% and 40% on the English exam, spanning writing, speaking, listening, and reading, indicates some understanding of key concepts in each domain. However, there's room for improvement in fundamental skills. Strengthening writing, speaking, listening, and reading abilities through consistent effort and focused study will contribute to overall proficiency.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "This student's performance on the English exam, encompassing writing, speaking, listening, and reading, reflects a significant need for improvement, scoring between 0% and 20%. There's a notable gap in understanding key concepts across all language domains. Strengthening fundamental skills in writing, speaking, listening, and reading is crucial. Developing a consistent study routine and seeking additional support in each area can contribute to substantial progress.";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLevelSummary = (score: number) => {
|
||||||
|
if (score > 0.8) {
|
||||||
|
return "Scoring between 81% and 100% on the English exam showcases an outstanding level of understanding and proficiency. Your performance reflects a mastery of key concepts, including grammar, vocabulary, and comprehension. You exhibit a high level of skill in applying these elements effectively. Your dedication to excellence is evident, and your consistent, stellar performance is commendable. Continue to challenge yourself with advanced material to further refine your already impressive command of the English language. Your commitment to excellence positions you as a standout student in English studies, and your achievements are a testament to your hard work and capability.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score > 0.6) {
|
||||||
|
return "Scoring between 61% and 80% on the English exam reflects a commendable level of understanding and proficiency. You have demonstrated a solid grasp of key concepts, including grammar, vocabulary, and comprehension. There's evidence of effective application of skills, but room for refinement and deeper exploration remains. Consistent effort in honing nuanced aspects of language will contribute to even greater mastery. Continue engaging with challenging material and seeking opportunities for advanced comprehension. With sustained dedication, you have the potential to elevate your performance to an exceptional level and further excel in your English studies.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score > 0.4) {
|
||||||
|
return "Scoring between 41% and 60% on the English exam reflects a moderate level of understanding. You demonstrate a grasp of some key concepts, but there's room for refinement in areas like grammar, vocabulary, and comprehension. Consistent effort and a strategic focus on weaker areas can lead to notable improvement. Engaging with supplementary resources and seeking feedback will further enhance your skills. With continued dedication, there's a solid foundation to build upon, and achieving a higher level of proficiency is within reach. Keep up the good work and aim for sustained progress in your English studies.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score > 0.2) {
|
||||||
|
return "Scoring between 21% and 40% on the English exam shows some understanding of key concepts, but there's still ample room for improvement. Strengthening foundational skills, such as grammar, vocabulary, and comprehension, is essential. Consistent effort and focused study can help bridge gaps in knowledge and elevate your performance. Consider seeking additional guidance or resources to refine your understanding of the material. With commitment and targeted improvements, you have the potential to make significant strides in your English proficiency.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Your performance on the English exam falls within the 0% to 20% range, indicating a need for improvement. There's room to enhance your grasp of fundamental concepts like grammar, vocabulary, and comprehension. Establishing a consistent study routine and seeking extra support can be beneficial. With dedication and targeted efforts, you have the potential to significantly boost your performance in upcoming assessments.";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPerformanceSummary = (module: Module, score: number) => {
|
||||||
|
if (module === "level") return getLevelSummary(score);
|
||||||
|
return getExamSummary(score);
|
||||||
|
};
|
||||||
|
interface SkillsFeedbackRequest {
|
||||||
|
code: Module;
|
||||||
|
name: string;
|
||||||
|
grade: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillsFeedbackResponse extends SkillsFeedbackRequest {
|
||||||
|
evaluation: string;
|
||||||
|
suggestions: string;
|
||||||
|
bullet_points?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSkillsFeedback = async (sections: SkillsFeedbackRequest[]) => {
|
||||||
|
const backendRequest = await axios.post(
|
||||||
|
`${process.env.BACKEND_URL}/grading_summary`,
|
||||||
|
{ sections },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return backendRequest.data?.sections;
|
||||||
|
};
|
||||||
|
|
||||||
|
// perform the request with several retries if needed
|
||||||
|
const handleSkillsFeedbackRequest = async (
|
||||||
|
sections: SkillsFeedbackRequest[]
|
||||||
|
): Promise<SkillsFeedbackResponse[] | null> => {
|
||||||
|
let i = 0;
|
||||||
|
try {
|
||||||
|
const data = await getSkillsFeedback(sections);
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
if (i < 3) {
|
||||||
|
i++;
|
||||||
|
return handleSkillsFeedbackRequest(sections);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getDefaultPDFStream(
|
||||||
|
stats: Stat[],
|
||||||
|
user: User,
|
||||||
|
qrcodeUrl: string
|
||||||
|
) {
|
||||||
|
const [stat] = stats;
|
||||||
|
// generate the QR code for the report
|
||||||
|
const qrcode = await generateQRCode(qrcodeUrl);
|
||||||
|
|
||||||
|
if (!qrcode) {
|
||||||
|
throw new Error("Failed to generate QR code");
|
||||||
|
}
|
||||||
|
|
||||||
|
// stats may contain multiple exams of the same type so we need to aggregate them
|
||||||
|
const results = stats
|
||||||
|
.reduce((accm: ModuleScore[], stat: Stat) => {
|
||||||
|
const { module, score } = stat;
|
||||||
|
|
||||||
|
const fixedModuleStr = module[0].toUpperCase() + module.substring(1);
|
||||||
|
if (accm.find((e: ModuleScore) => e.module === fixedModuleStr)) {
|
||||||
|
return accm.map((e: ModuleScore) => {
|
||||||
|
if (e.module === fixedModuleStr) {
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
score: e.score + score.correct,
|
||||||
|
total: e.total + score.total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
module: fixedModuleStr,
|
||||||
|
score: score.correct,
|
||||||
|
total: score.total,
|
||||||
|
code: module,
|
||||||
|
} as ModuleScore;
|
||||||
|
|
||||||
|
return [...accm, value];
|
||||||
|
}, [])
|
||||||
|
.map((moduleScore: ModuleScore) => {
|
||||||
|
const { score, total } = moduleScore;
|
||||||
|
// with all the scores aggreated we can calculate the band score for each module
|
||||||
|
const bandScore = calculateBandScore(
|
||||||
|
score,
|
||||||
|
total,
|
||||||
|
moduleScore.code as Module,
|
||||||
|
user.focus
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...moduleScore,
|
||||||
|
// generate the closest radial progress png for the score
|
||||||
|
png: getRadialProgressPNG("azul", score, total),
|
||||||
|
bandScore,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// get the skills feedback from the backend based on the module grade
|
||||||
|
const skillsFeedback = (await handleSkillsFeedbackRequest(
|
||||||
|
results.map(({ code, bandScore }) => ({
|
||||||
|
code,
|
||||||
|
name: moduleLabels[code],
|
||||||
|
grade: bandScore,
|
||||||
|
}))
|
||||||
|
)) as SkillsFeedbackResponse[];
|
||||||
|
|
||||||
|
if (!skillsFeedback) {
|
||||||
|
throw new Error("Failed to get skills feedback");
|
||||||
|
}
|
||||||
|
|
||||||
|
// assign the feedback to the results
|
||||||
|
const finalResults = results.map((result) => {
|
||||||
|
const feedback = skillsFeedback.find(
|
||||||
|
(f: SkillsFeedbackResponse) => f.code === result.code
|
||||||
|
);
|
||||||
|
|
||||||
|
if (feedback) {
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
evaluation: feedback?.evaluation,
|
||||||
|
suggestions: feedback?.suggestions,
|
||||||
|
bullet_points: feedback?.bullet_points,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// calculate the overall score out of all the aggregated results
|
||||||
|
const overallScore = results.reduce((accm, { score }) => accm + score, 0);
|
||||||
|
const overallTotal = results.reduce((accm, { total }) => accm + total, 0);
|
||||||
|
const overallResult = overallScore / overallTotal;
|
||||||
|
|
||||||
|
const overallPNG = getRadialProgressPNG(
|
||||||
|
"laranja",
|
||||||
|
overallScore,
|
||||||
|
overallTotal
|
||||||
|
);
|
||||||
|
|
||||||
|
// generate the overall detail report
|
||||||
|
const overallDetail = {
|
||||||
|
module: "Overall",
|
||||||
|
score: overallScore,
|
||||||
|
total: overallTotal,
|
||||||
|
png: overallPNG,
|
||||||
|
} as ModuleScore;
|
||||||
|
const testDetails = [overallDetail, ...finalResults];
|
||||||
|
|
||||||
|
// generate the performance summary based on the overall result
|
||||||
|
const performanceSummary = getPerformanceSummary(stat.module, overallResult);
|
||||||
|
|
||||||
|
const title = "ENGLISH SKILLS TEST RESULT REPORT";
|
||||||
|
const details = <SkillExamDetails testDetails={testDetails} />;
|
||||||
|
|
||||||
|
const demographicInformation =
|
||||||
|
user.demographicInformation as DemographicInformation;
|
||||||
|
return ReactPDF.renderToStream(
|
||||||
|
<TestReport
|
||||||
|
title={title}
|
||||||
|
date={moment(stat.date)
|
||||||
|
.tz(user.demographicInformation?.timezone || "UTC")
|
||||||
|
.format("ll HH:mm:ss")}
|
||||||
|
name={user.name}
|
||||||
|
email={user.email}
|
||||||
|
id={user.id}
|
||||||
|
gender={demographicInformation?.gender}
|
||||||
|
summary={performanceSummary}
|
||||||
|
testDetails={testDetails}
|
||||||
|
renderDetails={details}
|
||||||
|
logo={"public/logo_title.png"}
|
||||||
|
qrcode={qrcode}
|
||||||
|
summaryPNG={overallPNG}
|
||||||
|
summaryScore={`${Math.floor(overallResult * 100)}%`}
|
||||||
|
passportId={demographicInformation?.passport_id || ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPdfUrl(pdfStream: any, docsSnap: any) {
|
||||||
|
// generate the file ref for storage
|
||||||
|
const fileName = `${Date.now().toString()}.pdf`;
|
||||||
|
const refName = `exam_report/${fileName}`;
|
||||||
|
const fileRef = ref(storage, refName);
|
||||||
|
|
||||||
|
// upload the pdf to storage
|
||||||
|
const pdfBuffer = await streamToBuffer(pdfStream);
|
||||||
|
const snapshot = await uploadBytes(fileRef, pdfBuffer, {
|
||||||
|
contentType: "application/pdf",
|
||||||
|
});
|
||||||
|
|
||||||
|
// update the stats entries with the pdf url to prevent duplication
|
||||||
|
docsSnap.docs.forEach(async (doc: any) => {
|
||||||
|
await updateDoc(doc.ref, {
|
||||||
|
pdf: {
|
||||||
|
path: refName,
|
||||||
|
version: process.env.PDF_VERSION,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return getDownloadURL(fileRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
// verify if it's a logged user that is trying to export
|
||||||
|
if (req.session.user) {
|
||||||
|
const { id } = req.query as { id: string };
|
||||||
|
// fetch stats entries for this particular user with the requested exam session
|
||||||
|
const docsSnap = await getDocs(
|
||||||
|
query(collection(db, "stats"), where("session", "==", id))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (docsSnap.empty) {
|
||||||
|
res.status(400).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = docsSnap.docs.map((d) => d.data()) as Stat[];
|
||||||
|
// verify if the stats already have a pdf generated
|
||||||
|
const hasPDF = stats.find(
|
||||||
|
(s) => s.pdf?.path && s.pdf?.version === process.env.PDF_VERSION
|
||||||
|
);
|
||||||
|
// find the user that generated the stats
|
||||||
|
const statIndex = stats.findIndex((s) => s.user);
|
||||||
|
|
||||||
|
if (statIndex === -1) {
|
||||||
|
res.status(401).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userId = stats[statIndex].user;
|
||||||
|
|
||||||
|
if (hasPDF) {
|
||||||
|
// if it does, return the pdf url
|
||||||
|
const fileRef = ref(storage, hasPDF.pdf!.path);
|
||||||
|
const url = await getDownloadURL(fileRef);
|
||||||
|
|
||||||
|
res.status(200).end(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// generate the pdf report
|
||||||
|
const docUser = await getDoc(doc(db, "users", userId));
|
||||||
|
|
||||||
|
if (docUser.exists()) {
|
||||||
|
// we'll need the user in order to get the user data (name, email, focus, etc);
|
||||||
|
|
||||||
|
const [stat] = stats;
|
||||||
|
|
||||||
|
if (stat.module === "level") {
|
||||||
|
const user = docUser.data() as StudentUser;
|
||||||
|
|
||||||
|
const uniqueExercises = stats.map((s) => ({
|
||||||
|
name: "Gramar & Vocabulary",
|
||||||
|
result: `${s.score.correct}/${s.score.total}`,
|
||||||
|
}));
|
||||||
|
const dates = stats.map((s) => moment(s.date));
|
||||||
|
const timeSpent = `${
|
||||||
|
stats.reduce((accm, s: Stat) => accm + (s.timeSpent || 0), 0) / 60
|
||||||
|
} minutes`;
|
||||||
|
const score = stats.reduce((accm, s) => accm + s.score.correct, 0);
|
||||||
|
const corporateName = await getCorporateNameForStudent(userId);
|
||||||
|
const pdfStream = await ReactPDF.renderToStream(
|
||||||
|
<LevelTestReport
|
||||||
|
date={moment.max(dates).format("DD/MM/YYYY")}
|
||||||
|
name={user.name}
|
||||||
|
email={user.email}
|
||||||
|
id={stat.exam}
|
||||||
|
gender={user.demographicInformation?.gender || ""}
|
||||||
|
passportId={user.demographicInformation?.passport_id || ""}
|
||||||
|
corporateName={corporateName}
|
||||||
|
downloadDate={moment().format("DD/MM/YYYY")}
|
||||||
|
userId={userId}
|
||||||
|
uniqueExercises={uniqueExercises}
|
||||||
|
timeSpent={timeSpent}
|
||||||
|
score={score.toString()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const url = await getPdfUrl(pdfStream, docsSnap);
|
||||||
|
res.status(200).end(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const user = docUser.data() as User;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pdfStream = await getDefaultPDFStream(
|
||||||
|
stats,
|
||||||
|
user,
|
||||||
|
`${req.headers.origin || ""}${req.url}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const url = await getPdfUrl(pdfStream, docsSnap);
|
||||||
|
res.status(200).end(url);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(401).json({ ok: false });
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(401).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const { id } = req.query as { id: string };
|
||||||
|
const docsSnap = await getDocs(
|
||||||
|
query(collection(db, "stats"), where("session", "==", id))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (docsSnap.empty) {
|
||||||
|
res.status(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = docsSnap.docs.map((d) => d.data());
|
||||||
|
|
||||||
|
const hasPDF = stats.find((s) => s.pdf?.path);
|
||||||
|
|
||||||
|
if (hasPDF) {
|
||||||
|
const fileRef = ref(storage, hasPDF.pdf.path);
|
||||||
|
const url = await getDownloadURL(fileRef);
|
||||||
|
return res.redirect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
@@ -1,353 +0,0 @@
|
|||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
|
||||||
import {app, storage} from "@/firebase";
|
|
||||||
import {getFirestore, doc, getDoc, updateDoc, getDocs, query, collection, where} from "firebase/firestore";
|
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
|
||||||
import {sessionOptions} from "@/lib/session";
|
|
||||||
import ReactPDF from "@react-pdf/renderer";
|
|
||||||
import TestReport from "@/exams/pdf/test.report";
|
|
||||||
import {ref, uploadBytes, getDownloadURL} from "firebase/storage";
|
|
||||||
import {DemographicInformation, User} from "@/interfaces/user";
|
|
||||||
import {Module} from "@/interfaces";
|
|
||||||
import {ModuleScore} from "@/interfaces/module.scores";
|
|
||||||
import {SkillExamDetails} from "@/exams/pdf/details/skill.exam";
|
|
||||||
import {LevelExamDetails} from "@/exams/pdf/details/level.exam";
|
|
||||||
import {calculateBandScore} from "@/utils/score";
|
|
||||||
import axios from "axios";
|
|
||||||
import {moduleLabels} from "@/utils/moduleUtils";
|
|
||||||
import {generateQRCode, getRadialProgressPNG, streamToBuffer} from "@/utils/pdf";
|
|
||||||
import moment from "moment-timezone";
|
|
||||||
|
|
||||||
const db = getFirestore(app);
|
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
if (req.method === "GET") return get(req, res);
|
|
||||||
if (req.method === "POST") return post(req, res);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getExamSummary = (score: number) => {
|
|
||||||
if (score > 0.8) {
|
|
||||||
return "Scoring between 81% and 100% on the English exam demonstrates an outstanding level of proficiency in writing, speaking, listening, and reading. Mastery of key concepts is evident across all language domains, showcasing not only a high level of skill but also a dedication to excellence. Continuing to challenge oneself with advanced material in writing, speaking, listening, and reading will further refine the already impressive command of the English language.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (score > 0.6) {
|
|
||||||
return "Scoring between 61% and 80% on the English exam, encompassing writing, speaking, listening, and reading, reflects a commendable level of proficiency in each domain. There's evidence of a solid grasp of key concepts, and effective application of skills. Room for refinement and deeper exploration in writing, speaking, listening, and reading remains, presenting an opportunity for further mastery.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (score > 0.4) {
|
|
||||||
return "Scoring between 41% and 60% on the English exam across writing, speaking, listening, and reading demonstrates a moderate level of understanding in each domain. While there's a commendable grasp of key concepts, refining fundamental skills in writing, speaking, listening, and reading can lead to notable improvement. Consistent effort and targeted focus on weaker areas are recommended.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (score > 0.2) {
|
|
||||||
return "Scoring between 21% and 40% on the English exam, spanning writing, speaking, listening, and reading, indicates some understanding of key concepts in each domain. However, there's room for improvement in fundamental skills. Strengthening writing, speaking, listening, and reading abilities through consistent effort and focused study will contribute to overall proficiency.";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "This student's performance on the English exam, encompassing writing, speaking, listening, and reading, reflects a significant need for improvement, scoring between 0% and 20%. There's a notable gap in understanding key concepts across all language domains. Strengthening fundamental skills in writing, speaking, listening, and reading is crucial. Developing a consistent study routine and seeking additional support in each area can contribute to substantial progress.";
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLevelSummary = (score: number) => {
|
|
||||||
if (score > 0.8) {
|
|
||||||
return "Scoring between 81% and 100% on the English exam showcases an outstanding level of understanding and proficiency. Your performance reflects a mastery of key concepts, including grammar, vocabulary, and comprehension. You exhibit a high level of skill in applying these elements effectively. Your dedication to excellence is evident, and your consistent, stellar performance is commendable. Continue to challenge yourself with advanced material to further refine your already impressive command of the English language. Your commitment to excellence positions you as a standout student in English studies, and your achievements are a testament to your hard work and capability.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (score > 0.6) {
|
|
||||||
return "Scoring between 61% and 80% on the English exam reflects a commendable level of understanding and proficiency. You have demonstrated a solid grasp of key concepts, including grammar, vocabulary, and comprehension. There's evidence of effective application of skills, but room for refinement and deeper exploration remains. Consistent effort in honing nuanced aspects of language will contribute to even greater mastery. Continue engaging with challenging material and seeking opportunities for advanced comprehension. With sustained dedication, you have the potential to elevate your performance to an exceptional level and further excel in your English studies.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (score > 0.4) {
|
|
||||||
return "Scoring between 41% and 60% on the English exam reflects a moderate level of understanding. You demonstrate a grasp of some key concepts, but there's room for refinement in areas like grammar, vocabulary, and comprehension. Consistent effort and a strategic focus on weaker areas can lead to notable improvement. Engaging with supplementary resources and seeking feedback will further enhance your skills. With continued dedication, there's a solid foundation to build upon, and achieving a higher level of proficiency is within reach. Keep up the good work and aim for sustained progress in your English studies.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (score > 0.2) {
|
|
||||||
return "Scoring between 21% and 40% on the English exam shows some understanding of key concepts, but there's still ample room for improvement. Strengthening foundational skills, such as grammar, vocabulary, and comprehension, is essential. Consistent effort and focused study can help bridge gaps in knowledge and elevate your performance. Consider seeking additional guidance or resources to refine your understanding of the material. With commitment and targeted improvements, you have the potential to make significant strides in your English proficiency.";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "Your performance on the English exam falls within the 0% to 20% range, indicating a need for improvement. There's room to enhance your grasp of fundamental concepts like grammar, vocabulary, and comprehension. Establishing a consistent study routine and seeking extra support can be beneficial. With dedication and targeted efforts, you have the potential to significantly boost your performance in upcoming assessments.";
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPerformanceSummary = (module: Module, score: number) => {
|
|
||||||
if (module === "level") return getLevelSummary(score);
|
|
||||||
return getExamSummary(score);
|
|
||||||
};
|
|
||||||
interface SkillsFeedbackRequest {
|
|
||||||
code: Module;
|
|
||||||
name: string;
|
|
||||||
grade: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SkillsFeedbackResponse extends SkillsFeedbackRequest {
|
|
||||||
evaluation: string;
|
|
||||||
suggestions: string;
|
|
||||||
bullet_points?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const getSkillsFeedback = async (sections: SkillsFeedbackRequest[]) => {
|
|
||||||
const backendRequest = await axios.post(
|
|
||||||
`${process.env.BACKEND_URL}/grading_summary`,
|
|
||||||
{sections},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return backendRequest.data?.sections;
|
|
||||||
};
|
|
||||||
|
|
||||||
// perform the request with several retries if needed
|
|
||||||
const handleSkillsFeedbackRequest = async (sections: SkillsFeedbackRequest[]): Promise<SkillsFeedbackResponse[] | null> => {
|
|
||||||
let i = 0;
|
|
||||||
try {
|
|
||||||
const data = await getSkillsFeedback(sections);
|
|
||||||
return data;
|
|
||||||
} catch (err) {
|
|
||||||
if (i < 3) {
|
|
||||||
i++;
|
|
||||||
return handleSkillsFeedbackRequest(sections);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
// verify if it's a logged user that is trying to export
|
|
||||||
if (req.session.user) {
|
|
||||||
const {id} = req.query as {id: string};
|
|
||||||
// fetch stats entries for this particular user with the requested exam session
|
|
||||||
const docsSnap = await getDocs(query(collection(db, "stats"), where("session", "==", id)));
|
|
||||||
|
|
||||||
if (docsSnap.empty) {
|
|
||||||
res.status(400).end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = docsSnap.docs.map((d) => d.data());
|
|
||||||
// verify if the stats already have a pdf generated
|
|
||||||
const hasPDF = stats.find((s) => s.pdf?.path && s.pdf?.version === process.env.PDF_VERSION);
|
|
||||||
// find the user that generated the stats
|
|
||||||
const statIndex = stats.findIndex((s) => s.user);
|
|
||||||
|
|
||||||
if(statIndex === -1) {
|
|
||||||
res.status(401).json({ok: false});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const userId = stats[statIndex].user;
|
|
||||||
|
|
||||||
|
|
||||||
if (hasPDF) {
|
|
||||||
// if it does, return the pdf url
|
|
||||||
const fileRef = ref(storage, hasPDF.pdf.path);
|
|
||||||
const url = await getDownloadURL(fileRef);
|
|
||||||
|
|
||||||
res.status(200).end(url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// generate the pdf report
|
|
||||||
const docUser = await getDoc(doc(db, "users", userId));
|
|
||||||
|
|
||||||
if (docUser.exists()) {
|
|
||||||
// we'll need the user in order to get the user data (name, email, focus, etc);
|
|
||||||
const user = docUser.data() as User;
|
|
||||||
|
|
||||||
// generate the QR code for the report
|
|
||||||
const qrcode = await generateQRCode((req.headers.origin || "") + req.url);
|
|
||||||
|
|
||||||
if (!qrcode) {
|
|
||||||
res.status(500).json({ok: false});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// stats may contain multiple exams of the same type so we need to aggregate them
|
|
||||||
const results = (
|
|
||||||
stats.reduce((accm: ModuleScore[], {module, score}) => {
|
|
||||||
const fixedModuleStr = module[0].toUpperCase() + module.substring(1);
|
|
||||||
if (accm.find((e: ModuleScore) => e.module === fixedModuleStr)) {
|
|
||||||
return accm.map((e: ModuleScore) => {
|
|
||||||
if (e.module === fixedModuleStr) {
|
|
||||||
return {
|
|
||||||
...e,
|
|
||||||
score: e.score + score.correct,
|
|
||||||
total: e.total + score.total,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return e;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
...accm,
|
|
||||||
{
|
|
||||||
module: fixedModuleStr,
|
|
||||||
score: score.correct,
|
|
||||||
total: score.total,
|
|
||||||
code: module,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}, []) as ModuleScore[]
|
|
||||||
).map((moduleScore) => {
|
|
||||||
const {score, total} = moduleScore;
|
|
||||||
// with all the scores aggreated we can calculate the band score for each module
|
|
||||||
const bandScore = calculateBandScore(score, total, moduleScore.code as Module, user.focus);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...moduleScore,
|
|
||||||
// generate the closest radial progress png for the score
|
|
||||||
png: getRadialProgressPNG("azul", score, total),
|
|
||||||
bandScore,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// get the skills feedback from the backend based on the module grade
|
|
||||||
const skillsFeedback = (await handleSkillsFeedbackRequest(
|
|
||||||
results.map(({code, bandScore}) => ({
|
|
||||||
code,
|
|
||||||
name: moduleLabels[code],
|
|
||||||
grade: bandScore,
|
|
||||||
})),
|
|
||||||
)) as SkillsFeedbackResponse[];
|
|
||||||
|
|
||||||
if (!skillsFeedback) {
|
|
||||||
res.status(500).json({ok: false});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// assign the feedback to the results
|
|
||||||
const finalResults = results.map((result) => {
|
|
||||||
const feedback = skillsFeedback.find((f: SkillsFeedbackResponse) => f.code === result.code);
|
|
||||||
|
|
||||||
if (feedback) {
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
evaluation: feedback?.evaluation,
|
|
||||||
suggestions: feedback?.suggestions,
|
|
||||||
bullet_points: feedback?.bullet_points,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
|
|
||||||
// calculate the overall score out of all the aggregated results
|
|
||||||
const overallScore = results.reduce((accm, {score}) => accm + score, 0);
|
|
||||||
const overallTotal = results.reduce((accm, {total}) => accm + total, 0);
|
|
||||||
const overallResult = overallScore / overallTotal;
|
|
||||||
|
|
||||||
const overallPNG = getRadialProgressPNG("laranja", overallScore, overallTotal);
|
|
||||||
|
|
||||||
// generate the overall detail report
|
|
||||||
const overallDetail = {
|
|
||||||
module: "Overall",
|
|
||||||
score: overallScore,
|
|
||||||
total: overallTotal,
|
|
||||||
png: overallPNG,
|
|
||||||
} as ModuleScore;
|
|
||||||
const testDetails = [overallDetail, ...finalResults];
|
|
||||||
|
|
||||||
const [stat] = stats;
|
|
||||||
|
|
||||||
// generate the performance summary based on the overall result
|
|
||||||
const performanceSummary = getPerformanceSummary(stat.module, overallResult);
|
|
||||||
|
|
||||||
// level exams have a different report structure than the skill exams
|
|
||||||
const getCustomData = () => {
|
|
||||||
if (stat.module === "level") {
|
|
||||||
return {
|
|
||||||
title: "ENGLISH LEVEL TEST RESULT REPORT ",
|
|
||||||
details: <LevelExamDetails detail={overallDetail} title="Level as per CEFR Levels" />,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: "ENGLISH SKILLS TEST RESULT REPORT",
|
|
||||||
details: <SkillExamDetails testDetails={testDetails} />,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const {title, details} = getCustomData();
|
|
||||||
|
|
||||||
const demographicInformation = user.demographicInformation as DemographicInformation;
|
|
||||||
const pdfStream = await ReactPDF.renderToStream(
|
|
||||||
<TestReport
|
|
||||||
title={title}
|
|
||||||
date={moment(stat.date)
|
|
||||||
.tz(user.demographicInformation?.timezone || "UTC")
|
|
||||||
.format("ll HH:mm:ss")}
|
|
||||||
name={user.name}
|
|
||||||
email={user.email}
|
|
||||||
id={userId}
|
|
||||||
gender={demographicInformation?.gender}
|
|
||||||
summary={performanceSummary}
|
|
||||||
testDetails={testDetails}
|
|
||||||
renderDetails={details}
|
|
||||||
logo={"public/logo_title.png"}
|
|
||||||
qrcode={qrcode}
|
|
||||||
summaryPNG={overallPNG}
|
|
||||||
summaryScore={`${Math.floor(overallResult * 100)}%`}
|
|
||||||
passportId={demographicInformation?.passport_id || ""}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// generate the file ref for storage
|
|
||||||
const fileName = `${Date.now().toString()}.pdf`;
|
|
||||||
const refName = `exam_report/${fileName}`;
|
|
||||||
const fileRef = ref(storage, refName);
|
|
||||||
|
|
||||||
// upload the pdf to storage
|
|
||||||
const pdfBuffer = await streamToBuffer(pdfStream);
|
|
||||||
const snapshot = await uploadBytes(fileRef, pdfBuffer, {
|
|
||||||
contentType: "application/pdf",
|
|
||||||
});
|
|
||||||
|
|
||||||
// update the stats entries with the pdf url to prevent duplication
|
|
||||||
docsSnap.docs.forEach(async (doc) => {
|
|
||||||
await updateDoc(doc.ref, {
|
|
||||||
pdf: {
|
|
||||||
path: refName,
|
|
||||||
version: process.env.PDF_VERSION,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const url = await getDownloadURL(fileRef);
|
|
||||||
res.status(200).end(url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(401).json({ok: false});
|
|
||||||
return;
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ok: false});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(401).json({ok: false});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
const {id} = req.query as {id: string};
|
|
||||||
const docsSnap = await getDocs(query(collection(db, "stats"), where("session", "==", id)));
|
|
||||||
|
|
||||||
if (docsSnap.empty) {
|
|
||||||
res.status(404).end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = docsSnap.docs.map((d) => d.data());
|
|
||||||
|
|
||||||
const hasPDF = stats.find((s) => s.pdf?.path);
|
|
||||||
|
|
||||||
if (hasPDF) {
|
|
||||||
const fileRef = ref(storage, hasPDF.pdf.path);
|
|
||||||
const url = await getDownloadURL(fileRef);
|
|
||||||
return res.redirect(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(500).end();
|
|
||||||
}
|
|
||||||
@@ -53,7 +53,10 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
// based on the admin of each group, verify if it exists and it's of type corporate
|
// based on the admin of each group, verify if it exists and it's of type corporate
|
||||||
const groupsAdmins = [...new Set(groups.map((g) => g.admin).filter((id) => id))];
|
const groupsAdmins = [...new Set(groups.map((g) => g.admin).filter((id) => id))];
|
||||||
const adminsSnapshot = await getDocs(query(collection(db, "users"), where("id", "in", groupsAdmins), where("type", "==", "corporate")));
|
const adminsSnapshot =
|
||||||
|
groupsAdmins.length > 0
|
||||||
|
? await getDocs(query(collection(db, "users"), where("id", "in", groupsAdmins), where("type", "==", "corporate")))
|
||||||
|
: {docs: []};
|
||||||
const admins = adminsSnapshot.docs.map((doc) => doc.data());
|
const admins = adminsSnapshot.docs.map((doc) => doc.data());
|
||||||
|
|
||||||
const docsWithAdmins = docs.map((d) => {
|
const docsWithAdmins = docs.map((d) => {
|
||||||
|
|||||||
44
src/pages/api/training/[id].ts
Normal file
44
src/pages/api/training/[id].ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import {app} from "@/firebase";
|
||||||
|
import { collection, doc, getDoc, getDocs, getFirestore, query } from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET") return get(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
try {
|
||||||
|
const { id } = req.query;
|
||||||
|
|
||||||
|
if (typeof id !== 'string') {
|
||||||
|
return res.status(400).json({ message: 'Invalid ID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const docRef = doc(db, "training", id);
|
||||||
|
const docSnap = await getDoc(docRef);
|
||||||
|
|
||||||
|
if (docSnap.exists()) {
|
||||||
|
res.status(200).json({
|
||||||
|
id: docSnap.id,
|
||||||
|
...docSnap.data(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ message: 'Document not found' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
res.status(500).json({ message: 'An unexpected error occurred' });
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/pages/api/training/index.ts
Normal file
51
src/pages/api/training/index.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import axios from "axios";
|
||||||
|
import { app } from "@/firebase";
|
||||||
|
import { collection, doc, getDoc, getDocs, getFirestore, query } from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET") return get(req, res);
|
||||||
|
if (req.method === "POST") return post(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${process.env.BACKEND_URL}/training_content`, req.body, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.status(response.status).json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ message: 'An unexpected error occurred' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
try {
|
||||||
|
const q = query(collection(db, "training"));
|
||||||
|
const snapshot = await getDocs(q);
|
||||||
|
|
||||||
|
res.status(200).json(
|
||||||
|
snapshot.docs.map((doc) => ({
|
||||||
|
id: doc.id,
|
||||||
|
...doc.data(),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
res.status(500).json({ message: 'An unexpected error occurred' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
44
src/pages/api/training/walkthrough/index.ts
Normal file
44
src/pages/api/training/walkthrough/index.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import { app } from "@/firebase";
|
||||||
|
import { collection, doc, documentId, getDoc, getDocs, getFirestore, query, where } from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ ok: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method === "GET") return get(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
try {
|
||||||
|
const { ids } = req.query;
|
||||||
|
|
||||||
|
if (!ids || !Array.isArray(ids)) {
|
||||||
|
return res.status(400).json({ message: 'Invalid or missing ids!' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const walkthroughCollection = collection(db, 'walkthrough');
|
||||||
|
|
||||||
|
const q = query(walkthroughCollection, where(documentId(), 'in', ids));
|
||||||
|
|
||||||
|
const querySnapshot = await getDocs(q);
|
||||||
|
|
||||||
|
const documents = querySnapshot.docs.map(doc => ({
|
||||||
|
id: doc.id,
|
||||||
|
...doc.data()
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.status(200).json(documents);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
res.status(500).json({ message: 'An unexpected error occurred' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,12 @@
|
|||||||
import { PERMISSIONS } from "@/constants/userPermissions";
|
import {PERMISSIONS} from "@/constants/userPermissions";
|
||||||
import { app, adminApp } from "@/firebase";
|
import {app, adminApp} from "@/firebase";
|
||||||
import { Group, User } from "@/interfaces/user";
|
import {Group, User} from "@/interfaces/user";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {
|
import {collection, deleteDoc, doc, getDoc, getDocs, getFirestore, query, setDoc, where} from "firebase/firestore";
|
||||||
collection,
|
import {getAuth} from "firebase-admin/auth";
|
||||||
deleteDoc,
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
doc,
|
import {NextApiRequest, NextApiResponse} from "next";
|
||||||
getDoc,
|
import {getPermissions, getPermissionDocs} from "@/utils/permissions.be";
|
||||||
getDocs,
|
|
||||||
getFirestore,
|
|
||||||
query,
|
|
||||||
setDoc,
|
|
||||||
where,
|
|
||||||
} from "firebase/firestore";
|
|
||||||
import { getAuth } from "firebase-admin/auth";
|
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
|
||||||
import { NextApiRequest, NextApiResponse } from "next";
|
|
||||||
import { getPermissions, getPermissionDocs } from "@/utils/permissions.be";
|
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
const auth = getAuth(adminApp);
|
const auth = getAuth(adminApp);
|
||||||
@@ -24,132 +14,110 @@ const auth = getAuth(adminApp);
|
|||||||
export default withIronSessionApiRoute(user, sessionOptions);
|
export default withIronSessionApiRoute(user, sessionOptions);
|
||||||
|
|
||||||
async function user(req: NextApiRequest, res: NextApiResponse) {
|
async function user(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method === "GET") return get(req, res);
|
if (req.method === "GET") return get(req, res);
|
||||||
if (req.method === "DELETE") return del(req, res);
|
if (req.method === "DELETE") return del(req, res);
|
||||||
|
|
||||||
res.status(404).json(undefined);
|
res.status(404).json(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del(req: NextApiRequest, res: NextApiResponse) {
|
async function del(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
res.status(401).json({ ok: false });
|
res.status(401).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = req.query as { id: string };
|
const {id} = req.query as {id: string};
|
||||||
|
|
||||||
const docUser = await getDoc(doc(db, "users", req.session.user.id));
|
const docUser = await getDoc(doc(db, "users", req.session.user.id));
|
||||||
if (!docUser.exists()) {
|
if (!docUser.exists()) {
|
||||||
res.status(401).json({ ok: false });
|
res.status(401).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = docUser.data() as User;
|
const user = docUser.data() as User;
|
||||||
|
|
||||||
const docTargetUser = await getDoc(doc(db, "users", id));
|
const docTargetUser = await getDoc(doc(db, "users", id));
|
||||||
if (!docTargetUser.exists()) {
|
if (!docTargetUser.exists()) {
|
||||||
res.status(404).json({ ok: false });
|
res.status(404).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetUser = { ...docTargetUser.data(), id: docTargetUser.id } as User;
|
const targetUser = {...docTargetUser.data(), id: docTargetUser.id} as User;
|
||||||
|
|
||||||
if (
|
if (user.type === "corporate" && (targetUser.type === "student" || targetUser.type === "teacher")) {
|
||||||
user.type === "corporate" &&
|
res.json({ok: true});
|
||||||
(targetUser.type === "student" || targetUser.type === "teacher")
|
|
||||||
) {
|
|
||||||
res.json({ ok: true });
|
|
||||||
|
|
||||||
const userParticipantGroup = await getDocs(
|
const userParticipantGroup = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", id)));
|
||||||
query(
|
await Promise.all([
|
||||||
collection(db, "groups"),
|
...userParticipantGroup.docs
|
||||||
where("participants", "array-contains", id)
|
.filter((x) => (x.data() as Group).admin === user.id)
|
||||||
)
|
.map(
|
||||||
);
|
async (x) =>
|
||||||
await Promise.all([
|
await setDoc(
|
||||||
...userParticipantGroup.docs
|
x.ref,
|
||||||
.filter((x) => (x.data() as Group).admin === user.id)
|
{
|
||||||
.map(
|
participants: x.data().participants.filter((y: string) => y !== id),
|
||||||
async (x) =>
|
},
|
||||||
await setDoc(
|
{merge: true},
|
||||||
x.ref,
|
),
|
||||||
{
|
),
|
||||||
participants: x
|
]);
|
||||||
.data()
|
|
||||||
.participants.filter((y: string) => y !== id),
|
|
||||||
},
|
|
||||||
{ merge: true }
|
|
||||||
)
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const permission = PERMISSIONS.deleteUser[targetUser.type];
|
const permission = PERMISSIONS.deleteUser[targetUser.type];
|
||||||
if (!permission.list.includes(user.type)) {
|
if (!permission.list.includes(user.type)) {
|
||||||
res.status(403).json({ ok: false });
|
res.status(403).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ ok: true });
|
res.json({ok: true});
|
||||||
|
|
||||||
await auth.deleteUser(id);
|
await auth.deleteUser(id);
|
||||||
await deleteDoc(doc(db, "users", id));
|
await deleteDoc(doc(db, "users", id));
|
||||||
const userCodeDocs = await getDocs(
|
const userCodeDocs = await getDocs(query(collection(db, "codes"), where("userId", "==", id)));
|
||||||
query(collection(db, "codes"), where("userId", "==", id))
|
const userParticipantGroup = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", id)));
|
||||||
);
|
const userGroupAdminDocs = await getDocs(query(collection(db, "groups"), where("admin", "==", id)));
|
||||||
const userParticipantGroup = await getDocs(
|
const userStatsDocs = await getDocs(query(collection(db, "stats"), where("user", "==", id)));
|
||||||
query(collection(db, "groups"), where("participants", "array-contains", id))
|
|
||||||
);
|
|
||||||
const userGroupAdminDocs = await getDocs(
|
|
||||||
query(collection(db, "groups"), where("admin", "==", id))
|
|
||||||
);
|
|
||||||
const userStatsDocs = await getDocs(
|
|
||||||
query(collection(db, "stats"), where("user", "==", id))
|
|
||||||
);
|
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
...userCodeDocs.docs.map(async (x) => await deleteDoc(x.ref)),
|
...userCodeDocs.docs.map(async (x) => await deleteDoc(x.ref)),
|
||||||
...userGroupAdminDocs.docs.map(async (x) => await deleteDoc(x.ref)),
|
...userGroupAdminDocs.docs.map(async (x) => await deleteDoc(x.ref)),
|
||||||
...userStatsDocs.docs.map(async (x) => await deleteDoc(x.ref)),
|
...userStatsDocs.docs.map(async (x) => await deleteDoc(x.ref)),
|
||||||
...userParticipantGroup.docs.map(
|
...userParticipantGroup.docs.map(
|
||||||
async (x) =>
|
async (x) =>
|
||||||
await setDoc(
|
await setDoc(
|
||||||
x.ref,
|
x.ref,
|
||||||
{
|
{
|
||||||
participants: x.data().participants.filter((y: string) => y !== id),
|
participants: x.data().participants.filter((y: string) => y !== id),
|
||||||
},
|
},
|
||||||
{ merge: true }
|
{merge: true},
|
||||||
)
|
),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.session.user) {
|
if (req.session.user) {
|
||||||
const docUser = await getDoc(doc(db, "users", req.session.user.id));
|
const docUser = await getDoc(doc(db, "users", req.session.user.id));
|
||||||
if (!docUser.exists()) {
|
if (!docUser.exists()) {
|
||||||
res.status(401).json(undefined);
|
res.status(401).json(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = docUser.data() as User;
|
const user = docUser.data() as User;
|
||||||
|
await setDoc(docUser.ref, {lastLogin: new Date().toISOString()}, {merge: true});
|
||||||
const permissionDocs = await getPermissionDocs();
|
|
||||||
|
|
||||||
const userWithPermissions = {
|
req.session.user = {
|
||||||
...user,
|
...user,
|
||||||
permissions: getPermissions(req.session.user.id, permissionDocs),
|
id: req.session.user.id,
|
||||||
};
|
lastLogin: new Date(),
|
||||||
req.session.user = {
|
};
|
||||||
...userWithPermissions,
|
await req.session.save();
|
||||||
id: req.session.user.id,
|
|
||||||
};
|
|
||||||
await req.session.save();
|
|
||||||
|
|
||||||
res.json({ ...userWithPermissions, id: req.session.user.id });
|
res.json({...user, id: req.session.user.id});
|
||||||
} else {
|
} else {
|
||||||
res.status(401).json(undefined);
|
res.status(401).json(undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
/* 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 useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
import { toast, ToastContainer } from "react-toastify";
|
import {toast, 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 { useState } from "react";
|
import {useState} from "react";
|
||||||
import { Module } from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import { RadioGroup, Tab } from "@headlessui/react";
|
import {RadioGroup, Tab} from "@headlessui/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||||
import { capitalize } from "lodash";
|
import {capitalize} from "lodash";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import { Exercise, ReadingPart } from "@/interfaces/exam";
|
import {Exercise, ReadingPart} from "@/interfaces/exam";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import ReadingGeneration from "./(generation)/ReadingGeneration";
|
import ReadingGeneration from "./(generation)/ReadingGeneration";
|
||||||
@@ -21,109 +21,114 @@ import ListeningGeneration from "./(generation)/ListeningGeneration";
|
|||||||
import WritingGeneration from "./(generation)/WritingGeneration";
|
import WritingGeneration from "./(generation)/WritingGeneration";
|
||||||
import LevelGeneration from "./(generation)/LevelGeneration";
|
import LevelGeneration from "./(generation)/LevelGeneration";
|
||||||
import SpeakingGeneration from "./(generation)/SpeakingGeneration";
|
import SpeakingGeneration from "./(generation)/SpeakingGeneration";
|
||||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
import {checkAccess} from "@/utils/permissions";
|
||||||
|
|
||||||
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 (
|
if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "mastercorporate", "developer", "corporate"])) {
|
||||||
shouldRedirectHome(user) ||
|
return {
|
||||||
checkAccess(user, getTypesOfUser(["developer"]))
|
redirect: {
|
||||||
) {
|
destination: "/",
|
||||||
return {
|
permanent: false,
|
||||||
redirect: {
|
},
|
||||||
destination: "/",
|
};
|
||||||
permanent: false,
|
}
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: { user: req.session.user },
|
props: {user: req.session.user},
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
export default function Generation() {
|
export default function Generation() {
|
||||||
const [module, setModule] = useState<Module>("reading");
|
const [module, setModule] = useState<Module>("reading");
|
||||||
|
|
||||||
const { user } = useUser({ redirectTo: "/login" });
|
const {user} = useUser({redirectTo: "/login"});
|
||||||
|
|
||||||
return (
|
const [title, setTitle] = useState<string>("");
|
||||||
<>
|
return (
|
||||||
<Head>
|
<>
|
||||||
<title>Exam Generation | EnCoach</title>
|
<Head>
|
||||||
<meta
|
<title>Exam Generation | EnCoach</title>
|
||||||
name="description"
|
<meta
|
||||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
</Head>
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<ToastContainer />
|
</Head>
|
||||||
{user && (
|
<ToastContainer />
|
||||||
<Layout user={user} className="gap-6">
|
{user && (
|
||||||
<h1 className="text-2xl font-semibold">Exam Generation</h1>
|
<Layout user={user} className="gap-6">
|
||||||
<div className="flex flex-col gap-3">
|
<h1 className="text-2xl font-semibold">Exam Generation</h1>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">
|
<div className="flex flex-col gap-3">
|
||||||
Module
|
<Input
|
||||||
</label>
|
type="text"
|
||||||
<RadioGroup
|
placeholder="Insert a title here"
|
||||||
value={module}
|
name="title"
|
||||||
onChange={setModule}
|
label="Title"
|
||||||
className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between"
|
onChange={setTitle}
|
||||||
>
|
roundness="xl"
|
||||||
{[...MODULE_ARRAY].map((x) => (
|
defaultValue={title}
|
||||||
<RadioGroup.Option value={x} key={x}>
|
required
|
||||||
{({ checked }) => (
|
/>
|
||||||
<span
|
|
||||||
className={clsx(
|
<label className="font-normal text-base text-mti-gray-dim">Module</label>
|
||||||
"px-6 py-4 w-64 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
<RadioGroup
|
||||||
"transition duration-300 ease-in-out",
|
value={module}
|
||||||
x === "reading" &&
|
onChange={setModule}
|
||||||
(!checked
|
className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
|
||||||
? "bg-white border-mti-gray-platinum"
|
{[...MODULE_ARRAY].map((x) => (
|
||||||
: "bg-ielts-reading/70 border-ielts-reading text-white"),
|
<RadioGroup.Option value={x} key={x}>
|
||||||
x === "listening" &&
|
{({checked}) => (
|
||||||
(!checked
|
<span
|
||||||
? "bg-white border-mti-gray-platinum"
|
className={clsx(
|
||||||
: "bg-ielts-listening/70 border-ielts-listening text-white"),
|
"px-6 py-4 w-64 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
x === "writing" &&
|
"transition duration-300 ease-in-out",
|
||||||
(!checked
|
x === "reading" &&
|
||||||
? "bg-white border-mti-gray-platinum"
|
(!checked
|
||||||
: "bg-ielts-writing/70 border-ielts-writing text-white"),
|
? "bg-white border-mti-gray-platinum"
|
||||||
x === "speaking" &&
|
: "bg-ielts-reading/70 border-ielts-reading text-white"),
|
||||||
(!checked
|
x === "listening" &&
|
||||||
? "bg-white border-mti-gray-platinum"
|
(!checked
|
||||||
: "bg-ielts-speaking/70 border-ielts-speaking text-white"),
|
? "bg-white border-mti-gray-platinum"
|
||||||
x === "level" &&
|
: "bg-ielts-listening/70 border-ielts-listening text-white"),
|
||||||
(!checked
|
x === "writing" &&
|
||||||
? "bg-white border-mti-gray-platinum"
|
(!checked
|
||||||
: "bg-ielts-level/70 border-ielts-level text-white")
|
? "bg-white border-mti-gray-platinum"
|
||||||
)}
|
: "bg-ielts-writing/70 border-ielts-writing text-white"),
|
||||||
>
|
x === "speaking" &&
|
||||||
{capitalize(x)}
|
(!checked
|
||||||
</span>
|
? "bg-white border-mti-gray-platinum"
|
||||||
)}
|
: "bg-ielts-speaking/70 border-ielts-speaking text-white"),
|
||||||
</RadioGroup.Option>
|
x === "level" &&
|
||||||
))}
|
(!checked
|
||||||
</RadioGroup>
|
? "bg-white border-mti-gray-platinum"
|
||||||
</div>
|
: "bg-ielts-level/70 border-ielts-level text-white"),
|
||||||
{module === "reading" && <ReadingGeneration />}
|
)}>
|
||||||
{module === "listening" && <ListeningGeneration />}
|
{capitalize(x)}
|
||||||
{module === "writing" && <WritingGeneration />}
|
</span>
|
||||||
{module === "speaking" && <SpeakingGeneration />}
|
)}
|
||||||
{module === "level" && <LevelGeneration />}
|
</RadioGroup.Option>
|
||||||
</Layout>
|
))}
|
||||||
)}
|
</RadioGroup>
|
||||||
</>
|
</div>
|
||||||
);
|
{module === "reading" && <ReadingGeneration id={title} />}
|
||||||
|
{module === "listening" && <ListeningGeneration id={title} />}
|
||||||
|
{module === "writing" && <WritingGeneration id={title} />}
|
||||||
|
{module === "speaking" && <SpeakingGeneration id={title} />}
|
||||||
|
{module === "level" && <LevelGeneration id={title} />}
|
||||||
|
</Layout>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
119
src/pages/groups.tsx
Normal file
119
src/pages/groups.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -390,7 +390,7 @@ interface PaypalPaymentWithUserData extends PaypalPayment {
|
|||||||
email: string;
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const paypalFilterRows = [["email"], ["name"]];
|
const paypalFilterRows = [["email"], ["name"], ["orderId"], ["value"]];
|
||||||
export default function PaymentRecord() {
|
export default function PaymentRecord() {
|
||||||
const [selectedCorporateUser, setSelectedCorporateUser] = useState<User>();
|
const [selectedCorporateUser, setSelectedCorporateUser] = useState<User>();
|
||||||
const [selectedAgentUser, setSelectedAgentUser] = useState<User>();
|
const [selectedAgentUser, setSelectedAgentUser] = useState<User>();
|
||||||
@@ -414,6 +414,14 @@ export default function PaymentRecord() {
|
|||||||
const [endDate, setEndDate] = useState<Date | null>(
|
const [endDate, setEndDate] = useState<Date | null>(
|
||||||
moment().endOf("day").toDate()
|
moment().endOf("day").toDate()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [startDatePaymob, setStartDatePaymob] = useState<Date | null>(
|
||||||
|
moment("01/01/2023").toDate()
|
||||||
|
);
|
||||||
|
const [endDatePaymob, setEndDatePaymob] = useState<Date | null>(
|
||||||
|
moment().endOf("day").toDate()
|
||||||
|
);
|
||||||
|
|
||||||
const [paid, setPaid] = useState<Boolean | null>(IS_PAID_OPTIONS[0].value);
|
const [paid, setPaid] = useState<Boolean | null>(IS_PAID_OPTIONS[0].value);
|
||||||
const [commissionTransfer, setCommissionTransfer] = useState<Boolean | null>(
|
const [commissionTransfer, setCommissionTransfer] = useState<Boolean | null>(
|
||||||
IS_FILE_SUBMITTED_OPTIONS[0].value
|
IS_FILE_SUBMITTED_OPTIONS[0].value
|
||||||
@@ -866,11 +874,16 @@ export default function PaymentRecord() {
|
|||||||
|
|
||||||
const updatedPaypalPayments = useMemo(
|
const updatedPaypalPayments = useMemo(
|
||||||
() =>
|
() =>
|
||||||
paypalPayments.map((p) => {
|
paypalPayments
|
||||||
const user = users.find((x) => x.id === p.userId) as User;
|
.filter((p) => {
|
||||||
return { ...p, name: user?.name, email: user?.email };
|
const date = moment(p.createdAt);
|
||||||
}),
|
return date.isAfter(startDatePaymob) && date.isBefore(endDatePaymob);
|
||||||
[paypalPayments, users]
|
})
|
||||||
|
.map((p) => {
|
||||||
|
const user = users.find((x) => x.id === p.userId) as User;
|
||||||
|
return { ...p, name: user?.name, email: user?.email };
|
||||||
|
}),
|
||||||
|
[paypalPayments, users, startDatePaymob, endDatePaymob]
|
||||||
);
|
);
|
||||||
|
|
||||||
const paypalColumns = [
|
const paypalColumns = [
|
||||||
@@ -1469,6 +1482,44 @@ export default function PaymentRecord() {
|
|||||||
{renderTable(table as Table<Payment>)}
|
{renderTable(table as Table<Payment>)}
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide flex flex-col gap-8">
|
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide flex flex-col gap-8">
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"grid grid-cols-1 md:grid-cols-2 gap-8 w-full",
|
||||||
|
user.type !== "corporate" && "lg:grid-cols-3"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
|
Date
|
||||||
|
</label>
|
||||||
|
<ReactDatePicker
|
||||||
|
dateFormat="dd/MM/yyyy"
|
||||||
|
className="px-4 py-6 w-full text-sm text-center font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
|
selected={startDatePaymob}
|
||||||
|
startDate={startDatePaymob}
|
||||||
|
endDate={endDatePaymob}
|
||||||
|
selectsRange
|
||||||
|
showMonthDropdown
|
||||||
|
filterDate={(date: Date) =>
|
||||||
|
moment(date).isSameOrBefore(moment(new Date()))
|
||||||
|
}
|
||||||
|
onChange={([initialDate, finalDate]: [Date, Date]) => {
|
||||||
|
setStartDatePaymob(
|
||||||
|
initialDate ?? moment("01/01/2023").toDate()
|
||||||
|
);
|
||||||
|
if (finalDate) {
|
||||||
|
// basicly selecting a final day works as if I'm selecting the first
|
||||||
|
// minute of that day. this way it covers the whole day
|
||||||
|
setEndDatePaymob(
|
||||||
|
moment(finalDate).endOf("day").toDate()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEndDatePaymob(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{renderSearch()}
|
{renderSearch()}
|
||||||
{renderTable(paypalTable as Table<PaypalPaymentWithUserData>)}
|
{renderTable(paypalTable as Table<PaypalPaymentWithUserData>)}
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
|
|||||||
@@ -1,209 +1,209 @@
|
|||||||
/* 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 { useState } from "react";
|
import {useEffect, useState} from "react";
|
||||||
import { withIronSessionSsr } from "iron-session/next";
|
import {withIronSessionSsr} from "iron-session/next";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||||
import { Permission, PermissionType } from "@/interfaces/permissions";
|
import {Permission, PermissionType} from "@/interfaces/permissions";
|
||||||
import { getPermissionDoc } from "@/utils/permissions.be";
|
import {getPermissionDoc} from "@/utils/permissions.be";
|
||||||
import { User } from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import Layout from "@/components/High/Layout";
|
import Layout from "@/components/High/Layout";
|
||||||
import { getUsers } from "@/utils/users.be";
|
import {getUsers} from "@/utils/users.be";
|
||||||
import { BsTrash } from "react-icons/bs";
|
import {BsTrash} from "react-icons/bs";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { toast, ToastContainer } from "react-toastify";
|
import {toast, ToastContainer} from "react-toastify";
|
||||||
import {Type as UserType} from '@/interfaces/user'
|
import {Type as UserType} from "@/interfaces/user";
|
||||||
|
import {getGroups} from "@/utils/groups.be";
|
||||||
interface BasicUser {
|
interface BasicUser {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: UserType
|
type: UserType;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PermissionWithBasicUsers {
|
interface PermissionWithBasicUsers {
|
||||||
id: string;
|
id: string;
|
||||||
type: PermissionType;
|
type: PermissionType;
|
||||||
users: BasicUser[];
|
users: BasicUser[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async (context) => {
|
export const getServerSideProps = withIronSessionSsr(async (context) => {
|
||||||
const { req, params } = context;
|
const {req, params} = context;
|
||||||
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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!params?.id) {
|
if (!params?.id) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
destination: "/permissions",
|
destination: "/permissions",
|
||||||
permanent: false,
|
permanent: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch data from external API
|
// Fetch data from external API
|
||||||
const permission: Permission = await getPermissionDoc(params.id as string);
|
const permission: Permission = await getPermissionDoc(params.id as string);
|
||||||
|
|
||||||
const allUserData: User[] = await getUsers();
|
const allUserData: User[] = await getUsers();
|
||||||
|
const groups = await getGroups();
|
||||||
const users = allUserData.map((u) => ({
|
|
||||||
id: u.id,
|
|
||||||
name: u.name,
|
|
||||||
type: u.type
|
|
||||||
})) as BasicUser[];
|
|
||||||
|
|
||||||
// const res = await fetch("api/permissions");
|
|
||||||
// const permissions: Permission[] = await res.json();
|
|
||||||
// Pass data to the page via props
|
|
||||||
const usersData: BasicUser[] = permission.users.reduce(
|
|
||||||
(acc: BasicUser[], userId) => {
|
|
||||||
const user = users.find((u) => u.id === userId) as BasicUser;
|
|
||||||
if (user) {
|
|
||||||
acc.push(user);
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
const userGroups = groups.filter((x) => x.admin === user.id);
|
||||||
props: {
|
const filteredGroups =
|
||||||
// permissions: permissions.map((p) => ({ id: p.id, type: p.type })),
|
user.type === "corporate"
|
||||||
permission: {
|
? userGroups
|
||||||
...permission,
|
: user.type === "mastercorporate"
|
||||||
id: params.id,
|
? groups.filter((x) => userGroups.flatMap((y) => y.participants).includes(x.admin))
|
||||||
users: usersData,
|
: groups;
|
||||||
},
|
|
||||||
user: req.session.user,
|
const users = allUserData.map((u) => ({
|
||||||
users,
|
id: u.id,
|
||||||
},
|
name: u.name,
|
||||||
};
|
type: u.type,
|
||||||
|
})) as BasicUser[];
|
||||||
|
|
||||||
|
const filteredUsers = ["mastercorporate", "corporate"].includes(user.type)
|
||||||
|
? users.filter((u) => filteredGroups.flatMap((g) => g.participants).includes(u.id))
|
||||||
|
: users;
|
||||||
|
|
||||||
|
// const res = await fetch("api/permissions");
|
||||||
|
// const permissions: Permission[] = await res.json();
|
||||||
|
// Pass data to the page via props
|
||||||
|
const usersData: BasicUser[] = permission.users.reduce((acc: BasicUser[], userId) => {
|
||||||
|
const user = filteredUsers.find((u) => u.id === userId) as BasicUser;
|
||||||
|
if (!!user) acc.push(user);
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
// permissions: permissions.map((p) => ({ id: p.id, type: p.type })),
|
||||||
|
permission: {
|
||||||
|
...permission,
|
||||||
|
id: params.id,
|
||||||
|
users: usersData,
|
||||||
|
},
|
||||||
|
user: req.session.user,
|
||||||
|
users: filteredUsers,
|
||||||
|
},
|
||||||
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
permission: PermissionWithBasicUsers;
|
permission: PermissionWithBasicUsers;
|
||||||
user: User;
|
user: User;
|
||||||
users: BasicUser[];
|
users: BasicUser[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page(props: Props) {
|
export default function Page(props: Props) {
|
||||||
const { permission, user, users } = props;
|
const {permission, user, users} = props;
|
||||||
|
|
||||||
|
|
||||||
const [selectedUsers, setSelectedUsers] = useState<string[]>(() =>
|
|
||||||
permission.users.map((u) => u.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
const onChange = (value: any) => {
|
const [selectedUsers, setSelectedUsers] = useState<string[]>(() => permission.users.map((u) => u.id));
|
||||||
|
|
||||||
setSelectedUsers((prev) => {
|
|
||||||
if (value?.value) {
|
|
||||||
return [...prev, value?.value];
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const removeUser = (id: string) => {
|
|
||||||
setSelectedUsers((prev) => prev.filter((u) => u !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const update = async () => {
|
const onChange = (value: any) => {
|
||||||
|
setSelectedUsers((prev) => {
|
||||||
try {
|
if (value?.value) {
|
||||||
await axios.patch(`/api/permissions/${permission.id}`, {
|
return [...prev, value?.value];
|
||||||
users: selectedUsers,
|
}
|
||||||
});
|
return prev;
|
||||||
toast.success("Permission updated");
|
});
|
||||||
} catch (err) {
|
};
|
||||||
toast.error("Failed to update permission");
|
const removeUser = (id: string) => {
|
||||||
}
|
setSelectedUsers((prev) => prev.filter((u) => u !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const update = async () => {
|
||||||
<>
|
try {
|
||||||
<Head>
|
await axios.patch(`/api/permissions/${permission.id}`, {
|
||||||
<title>EnCoach</title>
|
users: selectedUsers,
|
||||||
<meta
|
});
|
||||||
name="description"
|
toast.success("Permission updated");
|
||||||
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
} catch (err) {
|
||||||
/>
|
toast.error("Failed to update permission");
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
}
|
||||||
<link rel="icon" href="/favicon.ico" />
|
};
|
||||||
</Head>
|
|
||||||
<ToastContainer />
|
return (
|
||||||
<Layout user={user} className="gap-6">
|
<>
|
||||||
<h1 className="text-2xl font-semibold">
|
<Head>
|
||||||
Permission: {permission.type as string}
|
<title>EnCoach</title>
|
||||||
</h1>
|
<meta
|
||||||
<div className="flex gap-3">
|
name="description"
|
||||||
<Select
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||||
value={null}
|
/>
|
||||||
options={users
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
.filter((u) => !selectedUsers.includes(u.id))
|
<link rel="icon" href="/favicon.ico" />
|
||||||
.map((u) => ({
|
</Head>
|
||||||
label: `${u?.type}-${u?.name}`,
|
<ToastContainer />
|
||||||
value: u.id,
|
<Layout user={user} className="gap-6">
|
||||||
}))}
|
<div className="flex flex-col gap-6 w-full h-[88vh] overflow-y-scroll scrollbar-hide rounded-xl">
|
||||||
onChange={onChange}
|
<h1 className="text-2xl font-semibold">Permission: {permission.type as string}</h1>
|
||||||
/>
|
<div className="flex gap-3">
|
||||||
<Button onClick={update}>Update</Button>
|
<Select
|
||||||
</div>
|
value={null}
|
||||||
<div className="flex flex-row justify-between">
|
options={users
|
||||||
<div className="flex flex-col gap-3">
|
.filter((u) => !selectedUsers.includes(u.id))
|
||||||
<h2>Blacklisted Users</h2>
|
.map((u) => ({
|
||||||
<div className="flex gap-3 flex-wrap">
|
label: `${u?.type}-${u?.name}`,
|
||||||
{selectedUsers.map((userId) => {
|
value: u.id,
|
||||||
const user = users.find((u) => u.id === userId);
|
}))}
|
||||||
return (
|
onChange={onChange}
|
||||||
<div
|
/>
|
||||||
className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4"
|
<Button onClick={update}>Update</Button>
|
||||||
key={userId}
|
</div>
|
||||||
>
|
<div className="flex flex-row justify-between">
|
||||||
<span className="text-base first-letter:uppercase">{user?.type}-{user?.name}</span>
|
<div className="flex flex-col gap-3">
|
||||||
<BsTrash
|
<h2>Blacklisted Users</h2>
|
||||||
style={{ cursor: "pointer" }}
|
<div className="flex gap-3 flex-wrap">
|
||||||
onClick={() => removeUser(userId)}
|
{selectedUsers.map((userId) => {
|
||||||
size={20}
|
const user = users.find((u) => u.id === userId);
|
||||||
/>
|
return (
|
||||||
</div>
|
<div className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4" key={userId}>
|
||||||
);
|
<span className="text-base first-letter:uppercase">
|
||||||
})}
|
{user?.type}-{user?.name}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
<BsTrash style={{cursor: "pointer"}} onClick={() => removeUser(userId)} size={20} />
|
||||||
<div className="flex flex-col gap-3">
|
</div>
|
||||||
<h2>Whitelisted Users</h2>
|
);
|
||||||
<div className="flex flex-col gap-3 flex-wrap">
|
})}
|
||||||
{users.filter(user => !selectedUsers.includes(user.id)).map((user) => {
|
</div>
|
||||||
return (
|
</div>
|
||||||
<div
|
<div className="flex flex-col gap-3">
|
||||||
className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4"
|
<h2>Whitelisted Users</h2>
|
||||||
key={user.id}
|
<div className="flex flex-col gap-3 flex-wrap">
|
||||||
>
|
{users
|
||||||
<span className="text-base first-letter:uppercase">{user?.type}-{user?.name}</span>
|
.filter((user) => !selectedUsers.includes(user.id))
|
||||||
</div>
|
.map((user) => {
|
||||||
);
|
return (
|
||||||
})}
|
<div className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4" key={user.id}>
|
||||||
</div>
|
<span className="text-base first-letter:uppercase">
|
||||||
</div>
|
{user?.type}-{user?.name}
|
||||||
</div>
|
</span>
|
||||||
</Layout>
|
</div>
|
||||||
</>
|
);
|
||||||
);
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user