Merge remote-tracking branch 'origin/develop' into feature/training-content
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000";
|
const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000";
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: false,
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
|
|||||||
62
package-lock.json
generated
62
package-lock.json
generated
@@ -41,6 +41,7 @@
|
|||||||
"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",
|
||||||
|
"firebase-scrypt": "^2.2.0",
|
||||||
"formidable": "^3.5.0",
|
"formidable": "^3.5.0",
|
||||||
"formidable-serverless": "^1.1.1",
|
"formidable-serverless": "^1.1.1",
|
||||||
"framer-motion": "^9.0.2",
|
"framer-motion": "^9.0.2",
|
||||||
@@ -4056,6 +4057,20 @@
|
|||||||
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz",
|
||||||
"integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw=="
|
"integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/babel-runtime": {
|
||||||
|
"version": "6.26.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
|
||||||
|
"integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==",
|
||||||
|
"dependencies": {
|
||||||
|
"core-js": "^2.4.0",
|
||||||
|
"regenerator-runtime": "^0.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/babel-runtime/node_modules/regenerator-runtime": {
|
||||||
|
"version": "0.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
|
||||||
|
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg=="
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -4619,6 +4634,13 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/core-js": {
|
||||||
|
"version": "2.6.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
|
||||||
|
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
|
||||||
|
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
|
||||||
|
"hasInstallScript": true
|
||||||
|
},
|
||||||
"node_modules/core-util-is": {
|
"node_modules/core-util-is": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
@@ -6224,6 +6246,17 @@
|
|||||||
"@google-cloud/storage": "^6.9.5"
|
"@google-cloud/storage": "^6.9.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/firebase-scrypt": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/firebase-scrypt/-/firebase-scrypt-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-36vJZVPFepErsNw+nBjb9cpM9wYPtcxk1bKN//vLdVkNPhaw1cogzwxtMs0s+dYg1gvBDakg2Q4ch8zAWAvnxA==",
|
||||||
|
"dependencies": {
|
||||||
|
"babel-runtime": "^6.26.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/firebase/node_modules/@firebase/util": {
|
"node_modules/firebase/node_modules/@firebase/util": {
|
||||||
"version": "1.9.3",
|
"version": "1.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.9.3.tgz",
|
||||||
@@ -14697,6 +14730,22 @@
|
|||||||
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz",
|
||||||
"integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw=="
|
"integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw=="
|
||||||
},
|
},
|
||||||
|
"babel-runtime": {
|
||||||
|
"version": "6.26.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
|
||||||
|
"integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==",
|
||||||
|
"requires": {
|
||||||
|
"core-js": "^2.4.0",
|
||||||
|
"regenerator-runtime": "^0.11.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"regenerator-runtime": {
|
||||||
|
"version": "0.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
|
||||||
|
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"balanced-match": {
|
"balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -15083,6 +15132,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
|
||||||
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw=="
|
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw=="
|
||||||
},
|
},
|
||||||
|
"core-js": {
|
||||||
|
"version": "2.6.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
|
||||||
|
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ=="
|
||||||
|
},
|
||||||
"core-util-is": {
|
"core-util-is": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
@@ -16352,6 +16406,14 @@
|
|||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"firebase-scrypt": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/firebase-scrypt/-/firebase-scrypt-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-36vJZVPFepErsNw+nBjb9cpM9wYPtcxk1bKN//vLdVkNPhaw1cogzwxtMs0s+dYg1gvBDakg2Q4ch8zAWAvnxA==",
|
||||||
|
"requires": {
|
||||||
|
"babel-runtime": "^6.26.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"flat-cache": {
|
"flat-cache": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
|
||||||
|
|||||||
@@ -28,7 +28,8 @@
|
|||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@use-gesture/react": "^10.3.1",
|
"@use-gesture/react": "^10.3.1",
|
||||||
"axios": "^1.3.5",
|
"axios": "^1",
|
||||||
|
"axios-cache-interceptor": "^1",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"chart.js": "^4.2.1",
|
"chart.js": "^4.2.1",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
"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",
|
||||||
|
"firebase-scrypt": "^2.2.0",
|
||||||
"formidable": "^3.5.0",
|
"formidable": "^3.5.0",
|
||||||
"formidable-serverless": "^1.1.1",
|
"formidable-serverless": "^1.1.1",
|
||||||
"framer-motion": "^9.0.2",
|
"framer-motion": "^9.0.2",
|
||||||
@@ -79,7 +81,7 @@
|
|||||||
"read-excel-file": "^5.7.1",
|
"read-excel-file": "^5.7.1",
|
||||||
"short-unique-id": "5.0.2",
|
"short-unique-id": "5.0.2",
|
||||||
"stripe": "^13.10.0",
|
"stripe": "^13.10.0",
|
||||||
"swr": "^2.1.3",
|
"swr": "^2.2.5",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwind-scrollbar-hide": "^1.1.7",
|
"tailwind-scrollbar-hide": "^1.1.7",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
@@ -90,6 +92,7 @@
|
|||||||
"zustand": "^4.3.6"
|
"zustand": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@simbathesailor/use-what-changed": "^2.0.0",
|
||||||
"@types/blob-stream": "^0.1.33",
|
"@types/blob-stream": "^0.1.33",
|
||||||
"@types/formidable": "^3.4.0",
|
"@types/formidable": "^3.4.0",
|
||||||
"@types/howler": "^2.2.11",
|
"@types/howler": "^2.2.11",
|
||||||
|
|||||||
@@ -21,14 +21,18 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DemographicInformationInput({user, mutateUser}: Props) {
|
export default function DemographicInformationInput({user, mutateUser}: Props) {
|
||||||
const [country, setCountry] = useState<string>();
|
const [country, setCountry] = useState(user.demographicInformation?.country);
|
||||||
const [phone, setPhone] = useState<string>();
|
const [phone, setPhone] = useState(user.demographicInformation?.phone);
|
||||||
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
|
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
|
||||||
const [gender, setGender] = useState<Gender>();
|
const [gender, setGender] = useState<Gender>();
|
||||||
const [employment, setEmployment] = useState<EmploymentStatus>();
|
const [employment, setEmployment] = useState<EmploymentStatus>();
|
||||||
const [position, setPosition] = useState<string>();
|
|
||||||
const [timezone, setTimezone] = useState<string>(moment.tz.guess());
|
const [timezone, setTimezone] = useState<string>(moment.tz.guess());
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [position, setPosition] = useState(
|
||||||
|
user.type === "corporate" || user.type === "mastercorporate"
|
||||||
|
? user.demographicInformation?.position
|
||||||
|
: user.demographicInformation?.employment,
|
||||||
|
);
|
||||||
|
|
||||||
const [companyName, setCompanyName] = useState<string>();
|
const [companyName, setCompanyName] = useState<string>();
|
||||||
const [commercialRegistration, setCommercialRegistration] = useState<string>();
|
const [commercialRegistration, setCommercialRegistration] = useState<string>();
|
||||||
@@ -85,7 +89,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
|||||||
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
||||||
<CountrySelect value={country} onChange={setCountry} />
|
<CountrySelect value={country} onChange={setCountry} />
|
||||||
</div>
|
</div>
|
||||||
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} placeholder="Enter phone number" required />
|
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} value={phone} placeholder="Enter phone number" required />
|
||||||
</div>
|
</div>
|
||||||
{user.type === "student" && (
|
{user.type === "student" && (
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
84
src/components/Exercises/FillBlanks/MCDropdown.tsx
Normal file
84
src/components/Exercises/FillBlanks/MCDropdown.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import React, { useRef, useEffect, useState } from 'react';
|
||||||
|
import { animated, useSpring } from '@react-spring/web';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
interface MCDropdownProps {
|
||||||
|
id: string;
|
||||||
|
options: { [key: string]: string };
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
selectedValue?: string;
|
||||||
|
className?: string;
|
||||||
|
width: number;
|
||||||
|
isOpen: boolean;
|
||||||
|
onToggle: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MCDropdown: React.FC<MCDropdownProps> = ({
|
||||||
|
id,
|
||||||
|
options,
|
||||||
|
onSelect,
|
||||||
|
selectedValue,
|
||||||
|
className = "relative",
|
||||||
|
width,
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
}) => {
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [contentHeight, setContentHeight] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (contentRef.current) {
|
||||||
|
setContentHeight(contentRef.current.scrollHeight);
|
||||||
|
}
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
const springProps = useSpring({
|
||||||
|
height: isOpen ? contentHeight : 0,
|
||||||
|
opacity: isOpen ? 1 : 0,
|
||||||
|
config: { tension: 300, friction: 30 }
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${className} inline-block`} style={{ width: `${width}px` }}>
|
||||||
|
<button
|
||||||
|
onClick={() => onToggle(id)}
|
||||||
|
className={
|
||||||
|
clsx("rounded-full hover:text-white transition duration-300 ease-in-out px-5 py-2 text-center w-full flex items-center justify-between",
|
||||||
|
selectedValue ? "bg-mti-purple text-white" : "bg-mti-purple-ultralight text-mti-purple-light"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate p-1">{selectedValue || 'Select an option'}</span>
|
||||||
|
<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, width: `${width}px` }}
|
||||||
|
className="absolute z-10 mt-1 overflow-hidden bg-white rounded-md shadow-lg"
|
||||||
|
>
|
||||||
|
<div ref={contentRef}>
|
||||||
|
{Object.entries(options).sort((a, b) => a[0].localeCompare(b[0])).map(([key, value]) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(value);
|
||||||
|
onToggle(id);
|
||||||
|
}}
|
||||||
|
className="p-4 hover:bg-mti-purple-ultralight cursor-pointer whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<span>{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</animated.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MCDropdown;
|
||||||
@@ -1,250 +1,239 @@
|
|||||||
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
|
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
|
import { Fragment, useCallback, useEffect, useMemo, useRef, 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";
|
import { v4 } from "uuid";
|
||||||
|
import MCDropdown from "./MCDropdown";
|
||||||
|
|
||||||
const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
const FillBlanks: React.FC<FillBlanksExercise & CommonProps> = ({
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
prompt,
|
prompt,
|
||||||
solutions,
|
solutions,
|
||||||
text,
|
text,
|
||||||
words,
|
words,
|
||||||
userSolutions,
|
userSolutions,
|
||||||
variant,
|
variant,
|
||||||
onNext,
|
onNext,
|
||||||
onBack,
|
onBack,
|
||||||
}) => {
|
}) => {
|
||||||
const { shuffles, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state);
|
const { shuffles, exam, partIndex, questionIndex, exerciseIndex } = useExamStore((state) => state);
|
||||||
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
|
const [answers, setAnswers] = useState<{ id: string; solution: string }[]>(userSolutions);
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
|
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
|
||||||
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
|
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [currentMCSelection, setCurrentMCSelection] = useState<{ id: string, selection: FillBlanksMCOption }>();
|
const excludeWordMCType = (x: any) => {
|
||||||
|
return typeof x === "string" ? x : (x as { letter: string; word: string });
|
||||||
|
};
|
||||||
|
|
||||||
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
|
useEffect(() => {
|
||||||
return Array.isArray(words) && words.every(
|
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type });
|
||||||
word => word && typeof word === 'object' && 'id' in word && 'options' in word
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
);
|
}, [hasExamEnded]);
|
||||||
}
|
|
||||||
|
|
||||||
const excludeWordMCType = (x: any) => {
|
let correctWords: any;
|
||||||
return typeof x === "string" ? x : x as { letter: string; word: string };
|
if (exam && (exam.module === "level" || exam.module === "reading") && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") {
|
||||||
}
|
correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words;
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) onNext({ exercise: id, solutions: answers, score: calculateScore(), type });
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
}, [hasExamEnded]);
|
setOpenDropdownId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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.letter.toLowerCase() === x.solution.toLowerCase();
|
||||||
|
} else {
|
||||||
|
return w.id.toString() === x.id.toString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!option) return false;
|
||||||
|
|
||||||
let correctWords: any;
|
if (typeof option === "string") {
|
||||||
if (exam && (exam.module === "level" || exam.module === "reading") && exam.parts[partIndex].exercises[exerciseIndex].type === "fillBlanks") {
|
return solution.toLowerCase() === option.toLowerCase();
|
||||||
correctWords = (exam.parts[partIndex].exercises[exerciseIndex] as FillBlanksExercise).words;
|
} 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 calculateScore = () => {
|
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
|
||||||
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.letter.toLowerCase() === x.solution.toLowerCase();
|
|
||||||
} else {
|
|
||||||
return w.id.toString() === x.id.toString();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!option) return false;
|
|
||||||
|
|
||||||
if (typeof option === "string") {
|
const renderLines = useCallback(
|
||||||
return solution.toLowerCase() === option.toLowerCase();
|
(line: string) => {
|
||||||
} else if ('letter' in option) {
|
return (
|
||||||
return solution.toLowerCase() === option.word.toLowerCase();
|
<div className="text-xl leading-5" key={v4()} ref={dropdownRef}>
|
||||||
} else if ('options' in option) {
|
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||||
return option.options[solution as keyof typeof option.options] == x.solution;
|
const id = match.replaceAll(/[\{\}]/g, "");
|
||||||
}
|
const userSolution = answers.find((x) => x.id === id);
|
||||||
return false;
|
const styles = clsx(
|
||||||
}).length;
|
"rounded-full hover:text-white transition duration-300 ease-in-out my-1 px-5 py-2 text-center w-fit",
|
||||||
const missing = total - answers!.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
|
!userSolution && "text-center text-mti-purple-light bg-mti-purple-ultralight",
|
||||||
return { total, correct, missing };
|
userSolution && "text-center text-mti-purple-dark bg-mti-purple-ultralight",
|
||||||
};
|
);
|
||||||
const renderLines = useCallback((line: string) => {
|
|
||||||
return (
|
|
||||||
<div className="text-base leading-5" key={v4()}>
|
|
||||||
{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 transition duration-300 ease-in-out my-1 px-5 py-2 text-center",
|
|
||||||
currentMCSelection?.id == id && "!bg-mti-purple !text-white !outline-none !ring-0",
|
|
||||||
!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>
|
|
||||||
);
|
|
||||||
}, [variant, words, setCurrentMCSelection, answers, currentMCSelection]);
|
|
||||||
|
|
||||||
const memoizedLines = useMemo(() => {
|
const currentSelection = words.find((x) => {
|
||||||
return text.split("\\n").map((line, index) => (
|
if (typeof x !== "string" && "id" in x) {
|
||||||
<p key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}>
|
return (x as FillBlanksMCOption).id.toString() == id.toString();
|
||||||
{renderLines(line)}
|
}
|
||||||
<br />
|
return false;
|
||||||
</p>
|
}) as FillBlanksMCOption;
|
||||||
));
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [text, variant, renderLines, currentMCSelection]);
|
|
||||||
|
|
||||||
|
return variant === "mc" ? (
|
||||||
|
<MCDropdown
|
||||||
|
id={id}
|
||||||
|
options={currentSelection.options}
|
||||||
|
onSelect={(value) => onSelection(id, value)}
|
||||||
|
selectedValue={userSolution?.solution}
|
||||||
|
className="inline-block py-2 px-1"
|
||||||
|
width={220}
|
||||||
|
isOpen={openDropdownId === id}
|
||||||
|
onToggle={()=> setOpenDropdownId(prevId => prevId === id ? null : id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
className={styles}
|
||||||
|
onChange={(e) => setAnswers((prev) => [...prev.filter((x) => x.id !== id), { id, solution: e.target.value }])}
|
||||||
|
value={userSolution?.solution}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div >
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[variant, words, answers, openDropdownId],
|
||||||
|
);
|
||||||
|
|
||||||
const onSelection = (questionID: string, value: string) => {
|
const memoizedLines = useMemo(() => {
|
||||||
setAnswers((prev) => [...prev.filter((x) => x.id !== questionID), { id: questionID, solution: value }]);
|
return text.split("\\n").map((line, index) => (
|
||||||
}
|
<p key={index} className={clsx(variant === "mc" && "whitespace-pre-wrap")}>
|
||||||
|
{renderLines(line)}
|
||||||
|
<br />
|
||||||
|
</p>
|
||||||
|
));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [text, variant, renderLines]);
|
||||||
|
|
||||||
useEffect(() => {
|
const onSelection = (questionID: string, value: string) => {
|
||||||
if (variant === "mc") {
|
setAnswers((prev) => [...prev.filter((x) => x.id !== questionID), { id: questionID, solution: value }]);
|
||||||
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
};
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [answers])
|
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<>
|
if (variant === "mc") {
|
||||||
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
||||||
{variant !== "mc" && <span className="text-sm w-full leading-6">
|
}
|
||||||
{prompt.split("\\n").map((line, index) => (
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
<Fragment key={index}>
|
}, [answers]);
|
||||||
{line}
|
|
||||||
<br />
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</span>}
|
|
||||||
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
|
||||||
{memoizedLines}
|
|
||||||
</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
|
return (
|
||||||
className={clsx(
|
<div className="flex flex-col gap-4">
|
||||||
"border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300",
|
<div className="flex justify-between w-full gap-8">
|
||||||
!!answers.find((x) => x.solution.toLocaleLowerCase() === value.toLocaleLowerCase() && x.id === currentMCSelection.id) &&
|
<Button
|
||||||
"bg-mti-purple-dark text-white",
|
color="purple"
|
||||||
)}
|
variant="outline"
|
||||||
key={v4()}
|
onClick={() => onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps })}
|
||||||
onClick={() => onSelection(currentMCSelection.id, value)}
|
className="max-w-[200px] w-full"
|
||||||
>
|
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
||||||
{value}
|
Previous Page
|
||||||
</button>;*/
|
</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 (
|
<Button
|
||||||
<span
|
color="purple"
|
||||||
className={clsx(
|
onClick={() => {
|
||||||
"border border-mti-purple-light rounded-full px-3 py-0.5 transition ease-in-out duration-300",
|
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
||||||
!!answers.find((x) => x.solution.toLowerCase() === (typeof v === "string" ? v : ("letter" in v ? v.letter : "")).toLowerCase()) &&
|
}}
|
||||||
"bg-mti-purple-dark text-white",
|
className="max-w-[200px] self-end w-full">
|
||||||
)}
|
Next Page
|
||||||
key={v4()}
|
</Button>
|
||||||
>
|
</div>
|
||||||
{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, shuffleMaps: shuffleMaps })}
|
|
||||||
className="max-w-[200px] w-full"
|
|
||||||
disabled={
|
|
||||||
exam && exam.module === "level" &&
|
|
||||||
typeof exam.parts[0].intro === "string" &&
|
|
||||||
partIndex === 0 &&
|
|
||||||
questionIndex === 0
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<div className="flex flex-col gap-4 mt-4 h-full w-full mb-20">
|
||||||
color="purple"
|
{variant !== "mc" && (
|
||||||
onClick={() => { onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps }) }}
|
<span className="text-sm w-full leading-6">
|
||||||
className="max-w-[200px] self-end w-full">
|
{prompt.split("\\n").map((line, index) => (
|
||||||
Next
|
<Fragment key={index}>
|
||||||
</Button>
|
{line}
|
||||||
</div>
|
<br />
|
||||||
</>
|
</Fragment>
|
||||||
);
|
))}
|
||||||
}
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">{memoizedLines}</span>
|
||||||
|
{variant !== "mc" && (
|
||||||
|
<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, shuffleMaps: shuffleMaps })}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
||||||
|
Previous Page
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => {
|
||||||
|
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
||||||
|
}}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default FillBlanks;
|
export default FillBlanks;
|
||||||
|
|||||||
@@ -152,139 +152,8 @@ export default function InteractiveSpeaking({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full gap-9">
|
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||||
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
<div className="flex justify-between w-full gap-8">
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<span className="font-semibold">{!!first_title && !!second_title ? `${first_title} & ${second_title}` : title}</span>
|
|
||||||
</div>
|
|
||||||
{prompts && prompts.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-4 w-full items-center">
|
|
||||||
<video key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
|
|
||||||
<source src={prompts[questionIndex].video_url} />
|
|
||||||
</video>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ReactMediaRecorder
|
|
||||||
audio
|
|
||||||
key={questionIndex}
|
|
||||||
onStop={(blob) => setMediaBlob(blob)}
|
|
||||||
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">
|
|
||||||
<p className="text-base font-normal">Record your answer:</p>
|
|
||||||
<div className="flex gap-8 items-center justify-center py-8">
|
|
||||||
{status === "idle" && (
|
|
||||||
<>
|
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
|
||||||
{status === "idle" && (
|
|
||||||
<BsMicFill
|
|
||||||
onClick={() => {
|
|
||||||
setRecordingDuration(0);
|
|
||||||
startRecording();
|
|
||||||
setIsRecording(true);
|
|
||||||
}}
|
|
||||||
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{status === "recording" && (
|
|
||||||
<>
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<span className="text-xs w-9">
|
|
||||||
{Math.floor(recordingDuration / 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
:
|
|
||||||
{Math.floor(recordingDuration % 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<BsPauseCircle
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(false);
|
|
||||||
pauseRecording();
|
|
||||||
}}
|
|
||||||
className="text-red-500 w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<BsCheckCircleFill
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(false);
|
|
||||||
stopRecording();
|
|
||||||
}}
|
|
||||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{status === "paused" && (
|
|
||||||
<>
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<span className="text-xs w-9">
|
|
||||||
{Math.floor(recordingDuration / 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
:
|
|
||||||
{Math.floor(recordingDuration % 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<BsPlayCircle
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(true);
|
|
||||||
resumeRecording();
|
|
||||||
}}
|
|
||||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<BsCheckCircleFill
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(false);
|
|
||||||
stopRecording();
|
|
||||||
}}
|
|
||||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{status === "stopped" && mediaBlobUrl && (
|
|
||||||
<>
|
|
||||||
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<BsTrashFill
|
|
||||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
|
||||||
onClick={() => {
|
|
||||||
setRecordingDuration(0);
|
|
||||||
clearBlobUrl();
|
|
||||||
setMediaBlob(undefined);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BsMicFill
|
|
||||||
onClick={() => {
|
|
||||||
clearBlobUrl();
|
|
||||||
setRecordingDuration(0);
|
|
||||||
startRecording();
|
|
||||||
setIsRecording(true);
|
|
||||||
setMediaBlob(undefined);
|
|
||||||
}}
|
|
||||||
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8">
|
|
||||||
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
@@ -292,6 +161,148 @@ export default function InteractiveSpeaking({
|
|||||||
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col h-full w-full gap-9">
|
||||||
|
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<span className="font-semibold">{!!first_title && !!second_title ? `${first_title} & ${second_title}` : title}</span>
|
||||||
|
</div>
|
||||||
|
{prompts && prompts.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-4 w-full items-center">
|
||||||
|
<video key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
|
||||||
|
<source src={prompts[questionIndex].video_url} />
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReactMediaRecorder
|
||||||
|
audio
|
||||||
|
key={questionIndex}
|
||||||
|
onStop={(blob) => setMediaBlob(blob)}
|
||||||
|
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">
|
||||||
|
<p className="text-base font-normal">Record your answer:</p>
|
||||||
|
<div className="flex gap-8 items-center justify-center py-8">
|
||||||
|
{status === "idle" && (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
{status === "idle" && (
|
||||||
|
<BsMicFill
|
||||||
|
onClick={() => {
|
||||||
|
setRecordingDuration(0);
|
||||||
|
startRecording();
|
||||||
|
setIsRecording(true);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "recording" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<span className="text-xs w-9">
|
||||||
|
{Math.floor(recordingDuration / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(recordingDuration % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsPauseCircle
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
pauseRecording();
|
||||||
|
}}
|
||||||
|
className="text-red-500 w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<BsCheckCircleFill
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
stopRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "paused" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<span className="text-xs w-9">
|
||||||
|
{Math.floor(recordingDuration / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(recordingDuration % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsPlayCircle
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(true);
|
||||||
|
resumeRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<BsCheckCircleFill
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
stopRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "stopped" && mediaBlobUrl && (
|
||||||
|
<>
|
||||||
|
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsTrashFill
|
||||||
|
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||||
|
onClick={() => {
|
||||||
|
setRecordingDuration(0);
|
||||||
|
clearBlobUrl();
|
||||||
|
setMediaBlob(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BsMicFill
|
||||||
|
onClick={() => {
|
||||||
|
clearBlobUrl();
|
||||||
|
setRecordingDuration(0);
|
||||||
|
startRecording();
|
||||||
|
setIsRecording(true);
|
||||||
|
setMediaBlob(undefined);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="self-end flex justify-between w-full gap-8">
|
||||||
|
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
|
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
|
||||||
|
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [answers, setAnswers]);
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
if (event.over && event.over.id.toString().startsWith("droppable")) {
|
if (event.over && event.over.id.toString().startsWith("droppable")) {
|
||||||
const optionID = event.active.id.toString().replace("draggable_option_", "");
|
const optionID = event.active.id.toString().replace("draggable_option_", "");
|
||||||
@@ -93,7 +100,24 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
}, [hasExamEnded]);
|
}, [hasExamEnded]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
|
<div className="flex justify-between w-full gap-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>
|
||||||
|
|
||||||
<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">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
@@ -143,6 +167,6 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import { MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap } 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";
|
||||||
import { v4 } from "uuid";
|
import {v4} from "uuid";
|
||||||
|
|
||||||
function Question({
|
function Question({
|
||||||
id,
|
id,
|
||||||
@@ -18,9 +18,8 @@ function Question({
|
|||||||
}: MultipleChoiceQuestion & {
|
}: MultipleChoiceQuestion & {
|
||||||
userSolution: string | undefined;
|
userSolution: string | undefined;
|
||||||
onSelectOption?: (option: string) => void;
|
onSelectOption?: (option: string) => void;
|
||||||
showSolution?: boolean,
|
showSolution?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
|
||||||
const renderPrompt = (prompt: string) => {
|
const renderPrompt = (prompt: string) => {
|
||||||
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
|
return reactStringReplace(prompt, /(<u>.*?<\/u>)/g, (match) => {
|
||||||
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
|
const word = match.replaceAll("<u>", "").replaceAll("</u>", "");
|
||||||
@@ -49,7 +48,9 @@ function Question({
|
|||||||
"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",
|
"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 key={v4()} className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>{option.id.toString()}</span>
|
<span key={v4()} className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>
|
||||||
|
{option.id.toString()}
|
||||||
|
</span>
|
||||||
<img src={option.src!} alt={`Option ${option.id.toString()}`} />
|
<img src={option.src!} alt={`Option ${option.id.toString()}`} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -60,7 +61,7 @@ function Question({
|
|||||||
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-base select-none",
|
"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() && "!bg-mti-purple-light !text-white",
|
||||||
)}>
|
)}>
|
||||||
<span className="font-semibold">{option.id.toString()}.</span>
|
<span className="font-semibold">{option.id.toString()}.</span>
|
||||||
<span>{option.text}</span>
|
<span>{option.text}</span>
|
||||||
@@ -71,38 +72,30 @@ 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 {
|
const {questionIndex, exerciseIndex, exam, shuffles, hasExamEnded, partIndex, setQuestionIndex, setCurrentSolution} = useExamStore(
|
||||||
questionIndex,
|
(state) => state,
|
||||||
exerciseIndex,
|
);
|
||||||
exam,
|
|
||||||
shuffles,
|
|
||||||
hasExamEnded,
|
|
||||||
partIndex,
|
|
||||||
setQuestionIndex,
|
|
||||||
setCurrentSolution
|
|
||||||
} = useExamStore((state) => state);
|
|
||||||
|
|
||||||
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
|
const shuffleMaps = shuffles.find((x) => x.exerciseID == id)?.shuffles;
|
||||||
|
|
||||||
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(() => {
|
||||||
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, question: MultipleChoiceQuestion) => {
|
||||||
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 }]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentSolution({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [answers, setAnswers])
|
}, [answers, setAnswers]);
|
||||||
|
|
||||||
const getShuffledSolution = (originalSolution: string, questionShuffleMap: ShuffleMap) => {
|
const getShuffledSolution = (originalSolution: string, questionShuffleMap: ShuffleMap) => {
|
||||||
for (const [newPosition, originalPosition] of Object.entries(questionShuffleMap.map)) {
|
for (const [newPosition, originalPosition] of Object.entries(questionShuffleMap.map)) {
|
||||||
@@ -111,8 +104,7 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return originalSolution;
|
return originalSolution;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = questions.length;
|
const total = questions.length;
|
||||||
@@ -135,63 +127,95 @@ export default function MultipleChoice({ id, prompt, type, questions, userSoluti
|
|||||||
return isSolutionCorrect || false;
|
return isSolutionCorrect || false;
|
||||||
}).length;
|
}).length;
|
||||||
const missing = total - answers!.filter((x) => questions.find((y) => x.question.toString() === y.id.toString())).length;
|
const missing = total - answers!.filter((x) => questions.find((y) => x.question.toString() === y.id.toString())).length;
|
||||||
return { total, correct, missing };
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
if (questionIndex === questions.length - 1) {
|
if (questionIndex + 1 >= questions.length - 1) {
|
||||||
onNext({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
onNext({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
|
||||||
} else {
|
} else {
|
||||||
setQuestionIndex(questionIndex + 1);
|
setQuestionIndex(questionIndex + 2);
|
||||||
}
|
}
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
};
|
};
|
||||||
|
|
||||||
const back = () => {
|
const back = () => {
|
||||||
if (questionIndex === 0) {
|
if (questionIndex === 0) {
|
||||||
onBack({ exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps });
|
onBack({exercise: id, solutions: answers, score: calculateScore(), type, shuffleMaps: shuffleMaps});
|
||||||
} else {
|
} else {
|
||||||
if (exam?.module === "level" && typeof exam.parts[0].intro !== "undefined" && questionIndex === 0) return;
|
if (exam?.module === "level" && typeof exam.parts[0].intro !== "undefined" && questionIndex === 0) return;
|
||||||
setQuestionIndex(questionIndex - 1);
|
setQuestionIndex(questionIndex - 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4">
|
||||||
<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 justify-between w-full gap-8">
|
||||||
{/*<span className="text-xl font-semibold mb-2">{"Select the appropriate option."}</span>*/}
|
<Button
|
||||||
{questionIndex < questions.length && (
|
color="purple"
|
||||||
<Question
|
variant="outline"
|
||||||
{...questions[questionIndex]}
|
onClick={back}
|
||||||
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
|
className="max-w-[200px] w-full"
|
||||||
onSelectOption={onSelectOption}
|
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</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={back} className="max-w-[200px] w-full"
|
|
||||||
disabled={
|
|
||||||
exam && exam.module === "level" &&
|
|
||||||
typeof exam.parts[0].intro === "string" &&
|
|
||||||
partIndex === 0 &&
|
|
||||||
questionIndex === 0
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
{
|
{exam &&
|
||||||
exam && exam.module === "level" &&
|
exam.module === "level" &&
|
||||||
partIndex === exam.parts.length - 1 &&
|
partIndex === exam.parts.length - 1 &&
|
||||||
exerciseIndex === exam.parts[partIndex].exercises.length - 1 &&
|
exerciseIndex === exam.parts[partIndex].exercises.length - 1 &&
|
||||||
questionIndex === questions.length - 1
|
questionIndex + 1 >= questions.length - 1
|
||||||
? "Submit" : "Next"}
|
? "Submit"
|
||||||
|
: "Next"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
|
<div className="flex flex-col gap-4 mt-4 mb-20">
|
||||||
|
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
|
{/*<span className="text-xl font-semibold mb-2">{"Select the appropriate option."}</span>*/}
|
||||||
|
{questionIndex < questions.length && (
|
||||||
|
<Question
|
||||||
|
{...questions[questionIndex]}
|
||||||
|
userSolution={answers.find((x) => questions[questionIndex].id === x.question)?.option}
|
||||||
|
onSelectOption={(option) => onSelectOption(option, questions[questionIndex])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{questionIndex + 1 < questions.length && (
|
||||||
|
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
|
<Question
|
||||||
|
{...questions[questionIndex + 1]}
|
||||||
|
userSolution={answers.find((x) => questions[questionIndex + 1].id === x.question)?.option}
|
||||||
|
onSelectOption={(option) => onSelectOption(option, questions[questionIndex + 1])}
|
||||||
|
/>
|
||||||
|
</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={back}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && exam.module === "level" && partIndex === 0 && questionIndex === 0}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
|
{exam &&
|
||||||
|
exam.module === "level" &&
|
||||||
|
partIndex === exam.parts.length - 1 &&
|
||||||
|
exerciseIndex === exam.parts[partIndex].exercises.length - 1 &&
|
||||||
|
questionIndex + 1 >= questions.length - 1
|
||||||
|
? "Submit"
|
||||||
|
: "Next"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
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>();
|
||||||
@@ -28,7 +28,7 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
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.", "");
|
||||||
|
|
||||||
@@ -42,8 +42,8 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -79,8 +79,8 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -88,8 +88,8 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -98,7 +98,7 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
const newText = e.target.value;
|
const newText = e.target.value;
|
||||||
const words = newText.match(/\S+/g);
|
const words = newText.match(/\S+/g);
|
||||||
const wordCount = words ? words.length : 0;
|
const wordCount = words ? words.length : 0;
|
||||||
|
|
||||||
if (wordCount <= 100) {
|
if (wordCount <= 100) {
|
||||||
setInputText(newText);
|
setInputText(newText);
|
||||||
} else {
|
} else {
|
||||||
@@ -110,188 +110,14 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
if (count > 100) break;
|
if (count > 100) break;
|
||||||
lastIndex = match.index! + match[0].length;
|
lastIndex = match.index! + match[0].length;
|
||||||
}
|
}
|
||||||
|
|
||||||
setInputText(newText.slice(0, lastIndex));
|
setInputText(newText.slice(0, lastIndex));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full gap-9">
|
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||||
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
|
<div className="flex justify-between w-full gap-8">
|
||||||
<div className="flex flex-col items-center justify-center gap-4 w-full h-full">
|
|
||||||
<div className="flex flex-col gap-1 ml-4">
|
|
||||||
{prompts.map((x, index) => (
|
|
||||||
<li className="italic" key={index}>
|
|
||||||
{x}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{!!suffix && <span className="font-bold">{suffix}</span>}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex flex-col gap-0">
|
|
||||||
<span className="font-semibold">{title}</span>
|
|
||||||
{prompts.length > 0 && (
|
|
||||||
<span className="font-semibold">You should talk for at least 1 minute and 30 seconds for your answer to be valid.</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!video_url && (
|
|
||||||
<span className="font-regular">
|
|
||||||
{text.split("\\n").map((line, index) => (
|
|
||||||
<Fragment key={index}>
|
|
||||||
<span>{line}</span>
|
|
||||||
<br />
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-6 items-center">
|
|
||||||
{video_url && (
|
|
||||||
<div className="flex flex-col gap-4 w-full items-center">
|
|
||||||
<video key={id} autoPlay controls className="max-w-3xl rounded-xl">
|
|
||||||
<source src={video_url} />
|
|
||||||
</video>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{prompts && prompts.length > 0 && <Button onClick={() => setIsPromptsModalOpen(true)}>View Prompts</Button>}
|
|
||||||
</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
|
|
||||||
audio
|
|
||||||
onStop={(blob) => setMediaBlob(blob)}
|
|
||||||
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">
|
|
||||||
<p className="text-base font-normal">Record your answer:</p>
|
|
||||||
<div className="flex gap-8 items-center justify-center py-8">
|
|
||||||
{status === "idle" && !mediaBlob && (
|
|
||||||
<>
|
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
|
||||||
{status === "idle" && (
|
|
||||||
<BsMicFill
|
|
||||||
onClick={() => {
|
|
||||||
setRecordingDuration(0);
|
|
||||||
startRecording();
|
|
||||||
setIsRecording(true);
|
|
||||||
}}
|
|
||||||
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{status === "recording" && (
|
|
||||||
<>
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<span className="text-xs w-9">
|
|
||||||
{Math.floor(recordingDuration / 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
:
|
|
||||||
{Math.floor(recordingDuration % 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<BsPauseCircle
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(false);
|
|
||||||
pauseRecording();
|
|
||||||
}}
|
|
||||||
className="text-red-500 w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<BsCheckCircleFill
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(false);
|
|
||||||
stopRecording();
|
|
||||||
}}
|
|
||||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{status === "paused" && (
|
|
||||||
<>
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<span className="text-xs w-9">
|
|
||||||
{Math.floor(recordingDuration / 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
:
|
|
||||||
{Math.floor(recordingDuration % 60)
|
|
||||||
.toString(10)
|
|
||||||
.padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<BsPlayCircle
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(true);
|
|
||||||
resumeRecording();
|
|
||||||
}}
|
|
||||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<BsCheckCircleFill
|
|
||||||
onClick={() => {
|
|
||||||
setIsRecording(false);
|
|
||||||
stopRecording();
|
|
||||||
}}
|
|
||||||
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
|
|
||||||
<>
|
|
||||||
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
|
||||||
<div className="flex gap-4 items-center">
|
|
||||||
<BsTrashFill
|
|
||||||
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
|
||||||
onClick={() => {
|
|
||||||
setRecordingDuration(0);
|
|
||||||
clearBlobUrl();
|
|
||||||
setMediaBlob(undefined);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BsMicFill
|
|
||||||
onClick={() => {
|
|
||||||
clearBlobUrl();
|
|
||||||
setRecordingDuration(0);
|
|
||||||
startRecording();
|
|
||||||
setIsRecording(true);
|
|
||||||
setMediaBlob(undefined);
|
|
||||||
}}
|
|
||||||
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="self-end flex justify-between w-full gap-8">
|
|
||||||
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
@@ -299,6 +125,193 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col h-full w-full gap-9">
|
||||||
|
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 w-full h-full">
|
||||||
|
<div className="flex flex-col gap-1 ml-4">
|
||||||
|
{prompts.map((x, index) => (
|
||||||
|
<li className="italic" key={index}>
|
||||||
|
{x}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!!suffix && <span className="font-bold">{suffix}</span>}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-0">
|
||||||
|
<span className="font-semibold">{title}</span>
|
||||||
|
{prompts.length > 0 && (
|
||||||
|
<span className="font-semibold">
|
||||||
|
You should talk for at least 1 minute and 30 seconds for your answer to be valid.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!video_url && (
|
||||||
|
<span className="font-regular">
|
||||||
|
{text.split("\\n").map((line, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<span>{line}</span>
|
||||||
|
<br />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-6 items-center">
|
||||||
|
{video_url && (
|
||||||
|
<div className="flex flex-col gap-4 w-full items-center">
|
||||||
|
<video key={id} autoPlay controls className="max-w-3xl rounded-xl">
|
||||||
|
<source src={video_url} />
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{prompts && prompts.length > 0 && <Button onClick={() => setIsPromptsModalOpen(true)}>View Prompts</Button>}
|
||||||
|
</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
|
||||||
|
audio
|
||||||
|
onStop={(blob) => setMediaBlob(blob)}
|
||||||
|
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">
|
||||||
|
<p className="text-base font-normal">Record your answer:</p>
|
||||||
|
<div className="flex gap-8 items-center justify-center py-8">
|
||||||
|
{status === "idle" && !mediaBlob && (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
{status === "idle" && (
|
||||||
|
<BsMicFill
|
||||||
|
onClick={() => {
|
||||||
|
setRecordingDuration(0);
|
||||||
|
startRecording();
|
||||||
|
setIsRecording(true);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "recording" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<span className="text-xs w-9">
|
||||||
|
{Math.floor(recordingDuration / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(recordingDuration % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsPauseCircle
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
pauseRecording();
|
||||||
|
}}
|
||||||
|
className="text-red-500 w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<BsCheckCircleFill
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
stopRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "paused" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<span className="text-xs w-9">
|
||||||
|
{Math.floor(recordingDuration / 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
:
|
||||||
|
{Math.floor(recordingDuration % 60)
|
||||||
|
.toString(10)
|
||||||
|
.padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsPlayCircle
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(true);
|
||||||
|
resumeRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<BsCheckCircleFill
|
||||||
|
onClick={() => {
|
||||||
|
setIsRecording(false);
|
||||||
|
stopRecording();
|
||||||
|
}}
|
||||||
|
className="text-mti-purple-light w-8 h-8 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
|
||||||
|
<>
|
||||||
|
<Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<BsTrashFill
|
||||||
|
className="text-mti-gray-cool cursor-pointer w-5 h-5"
|
||||||
|
onClick={() => {
|
||||||
|
setRecordingDuration(0);
|
||||||
|
clearBlobUrl();
|
||||||
|
setMediaBlob(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BsMicFill
|
||||||
|
onClick={() => {
|
||||||
|
clearBlobUrl();
|
||||||
|
setRecordingDuration(0);
|
||||||
|
startRecording();
|
||||||
|
setIsRecording(true);
|
||||||
|
setMediaBlob(undefined);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="self-end flex justify-between w-full gap-8">
|
||||||
|
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
const [answers, setAnswers] = useState<{id: string; solution: "true" | "false" | "not_given"}[]>(userSolutions);
|
const [answers, setAnswers] = useState<{id: string; solution: "true" | "false" | "not_given"}[]>(userSolutions);
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||||
|
const setCurrentSolution = useExamStore((state) => state.setCurrentSolution);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
@@ -28,6 +29,11 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [answers, setAnswers]);
|
||||||
|
|
||||||
const toggleAnswer = (solution: "true" | "false" | "not_given", questionId: string) => {
|
const toggleAnswer = (solution: "true" | "false" | "not_given", questionId: string) => {
|
||||||
const answer = answers.find((x) => x.id === questionId);
|
const answer = answers.find((x) => x.id === questionId);
|
||||||
if (answer && answer.solution === solution) {
|
if (answer && answer.solution === solution) {
|
||||||
@@ -39,7 +45,24 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
|
<div className="flex justify-between w-full gap-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>
|
||||||
|
|
||||||
<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">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
@@ -116,6 +139,6 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ function Blank({
|
|||||||
export default function WriteBlanks({id, prompt, type, maxWords, solutions, userSolutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
|
export default function WriteBlanks({id, prompt, type, maxWords, solutions, userSolutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
|
||||||
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
|
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
|
||||||
|
|
||||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
const {hasExamEnded, setCurrentSolution} = useExamStore((state) => state);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
@@ -70,6 +70,11 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
|
|||||||
return {total, correct, missing};
|
return {total, correct, missing};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentSolution({exercise: id, solutions: answers, score: calculateScore(), type});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [answers, setAnswers]);
|
||||||
|
|
||||||
const renderLines = (line: string) => {
|
const renderLines = (line: string) => {
|
||||||
return (
|
return (
|
||||||
<span className="text-base leading-5">
|
<span className="text-base leading-5">
|
||||||
@@ -87,7 +92,24 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex justify-between w-full gap-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>
|
||||||
|
|
||||||
<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">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
@@ -123,6 +145,6 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,34 @@ export default function Writing({
|
|||||||
}, [inputText, wordCounter]);
|
}, [inputText, wordCounter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
onBack({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 100, total: 100, missing: 0}, type})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
disabled={!isSubmitEnabled}
|
||||||
|
onClick={() =>
|
||||||
|
onNext({
|
||||||
|
exercise: id,
|
||||||
|
solutions: [{id, solution: inputText.replaceAll(/\s{2,}/g, " ")}],
|
||||||
|
score: {correct: 100, total: 100, missing: 0},
|
||||||
|
type,
|
||||||
|
module: "writing",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{attachment && (
|
{attachment && (
|
||||||
<Transition show={isModalOpen} as={Fragment}>
|
<Transition show={isModalOpen} as={Fragment}>
|
||||||
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
||||||
@@ -170,6 +197,6 @@ export default function Writing({
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {FillBlanksExercise} from "@/interfaces/exam";
|
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
exercise: FillBlanksExercise;
|
exercise: FillBlanksExercise;
|
||||||
@@ -8,11 +9,16 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FillBlanksEdit = (props: Props) => {
|
const FillBlanksEdit = (props: Props) => {
|
||||||
const {exercise, updateExercise} = props;
|
const { exercise, updateExercise } = props;
|
||||||
|
|
||||||
|
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
|
||||||
|
return Array.isArray(words) && words.every((word) => word && typeof word === "object" && "id" in word && "options" in word);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type={exercise?.variant && exercise.variant === "mc" ? "textarea" : "text"}
|
||||||
label="Prompt"
|
label="Prompt"
|
||||||
name="prompt"
|
name="prompt"
|
||||||
required
|
required
|
||||||
@@ -24,18 +30,18 @@ const FillBlanksEdit = (props: Props) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type={exercise?.variant && exercise.variant === "mc" ? "textarea" : "text"}
|
||||||
label="Text"
|
label="Text"
|
||||||
name="text"
|
name="text"
|
||||||
required
|
required
|
||||||
value={exercise.text}
|
value={exercise.text}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
updateExercise({
|
updateExercise({
|
||||||
text: value,
|
text: exercise?.variant && exercise.variant === "mc" ? value : value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<h1>Solutions</h1>
|
<h1 className="mt-4">Solutions</h1>
|
||||||
<div className="w-full flex flex-wrap -mx-2">
|
<div className="w-full flex flex-wrap -mx-2">
|
||||||
{exercise.solutions.map((solution, index) => (
|
{exercise.solutions.map((solution, index) => (
|
||||||
<div key={solution.id} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
<div key={solution.id} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
||||||
@@ -47,33 +53,75 @@ const FillBlanksEdit = (props: Props) => {
|
|||||||
value={solution.solution}
|
value={solution.solution}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
updateExercise({
|
updateExercise({
|
||||||
solutions: exercise.solutions.map((sol) => (sol.id === solution.id ? {...sol, solution: value} : sol)),
|
solutions: exercise.solutions.map((sol) => (sol.id === solution.id ? { ...sol, solution: value } : sol)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<h1>Words</h1>
|
<h1 className="mt-4">Words</h1>
|
||||||
<div className="w-full flex flex-wrap -mx-2">
|
<div className={clsx(exercise?.variant && exercise.variant === "mc" ? "w-full flex flex-row" : "w-full flex flex-wrap -mx-2")}>
|
||||||
{exercise.words.map((word, index) => (
|
{exercise?.variant && exercise.variant === "mc" && typeCheckWordsMC(exercise.words) ?
|
||||||
<div key={index} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
(
|
||||||
<Input
|
<div className="flex flex-col w-full">
|
||||||
type="text"
|
{exercise.words.flatMap((mcOptions, wordIndex) =>
|
||||||
label={`Word ${index + 1}`}
|
<>
|
||||||
name="word"
|
<label className="font-semibold">{`Word ${wordIndex + 1}`}</label>
|
||||||
required
|
<div className="flex flex-row">
|
||||||
value={typeof word === "string" ? word : ("word" in word ? word.word : "")}
|
{Object.entries(mcOptions.options).map(([key, value], optionIndex) => (
|
||||||
onChange={(value) =>
|
<div key={`${wordIndex}-${optionIndex}-${key}`} className="flex sm:w-1/2 lg:w-1/4 px-2 mb-4">
|
||||||
updateExercise({
|
<Input
|
||||||
words: exercise.words.map((sol, idx) =>
|
type="text"
|
||||||
index === idx ? (typeof word === "string" ? value : {...word, word: value}) : sol,
|
label={`Option ${key}`}
|
||||||
),
|
name="word"
|
||||||
})
|
required
|
||||||
}
|
value={value}
|
||||||
/>
|
onChange={(newValue) =>
|
||||||
</div>
|
updateExercise({
|
||||||
))}
|
words: exercise.words.map((word, idx) =>
|
||||||
|
idx === wordIndex
|
||||||
|
? {
|
||||||
|
...(word as FillBlanksMCOption),
|
||||||
|
options: {
|
||||||
|
...(word as FillBlanksMCOption).options,
|
||||||
|
[key]: newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: word
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
:
|
||||||
|
(
|
||||||
|
exercise.words.map((word, index) => (
|
||||||
|
<div key={index} className="flex sm:w-1/2 lg:w-1/4 px-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label={`Word ${index + 1}`}
|
||||||
|
name="word"
|
||||||
|
required
|
||||||
|
value={typeof word === "string" ? word : ("word" in word ? word.word : "")}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateExercise({
|
||||||
|
words: exercise.words.map((sol, idx) =>
|
||||||
|
index === idx ? (typeof word === "string" ? value : { ...word, word: value }) : sol,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useState} from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
type: "email" | "text" | "password" | "tel" | "number";
|
type: "email" | "text" | "password" | "tel" | "number" | "textarea";
|
||||||
roundness?: "full" | "xl";
|
roundness?: "full" | "xl";
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -32,6 +32,20 @@ export default function Input({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
if (type === "textarea") {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
className="w-full h-full cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl min-h-[200px]"
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (type === "password") {
|
if (type === "password") {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import { moduleLabels } from "@/utils/moduleUtils";
|
import { moduleLabels } from "@/utils/moduleUtils";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Fragment, ReactNode, useCallback, useState } from "react";
|
import { ReactNode, 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 Timer from "./Timer";
|
import Timer from "./Timer";
|
||||||
import { Exam, LevelExam, MultipleChoiceExercise, ShuffleMap, UserSolution } from "@/interfaces/exam";
|
import { Exercise, LevelExam, MultipleChoiceExercise, ShuffleMap, UserSolution } from "@/interfaces/exam";
|
||||||
import { BsFillGrid3X3GapFill } from "react-icons/bs";
|
import { BsFillGrid3X3GapFill } from "react-icons/bs";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import Modal from "../Modal";
|
import Modal from "../Modal";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
@@ -17,6 +15,7 @@ import React from "react";
|
|||||||
interface Props {
|
interface Props {
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
module: Module;
|
module: Module;
|
||||||
|
examLabel?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
exerciseIndex: number;
|
exerciseIndex: number;
|
||||||
totalExercises: number;
|
totalExercises: number;
|
||||||
@@ -24,6 +23,7 @@ interface Props {
|
|||||||
partLabel?: string;
|
partLabel?: string;
|
||||||
showTimer?: boolean;
|
showTimer?: boolean;
|
||||||
showSolutions?: boolean;
|
showSolutions?: boolean;
|
||||||
|
currentExercise?: Exercise;
|
||||||
runOnClick?: ((questionIndex: number) => void) | undefined;
|
runOnClick?: ((questionIndex: number) => void) | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ export default function ModuleTitle({
|
|||||||
minTimer,
|
minTimer,
|
||||||
module,
|
module,
|
||||||
label,
|
label,
|
||||||
|
examLabel,
|
||||||
exerciseIndex,
|
exerciseIndex,
|
||||||
totalExercises,
|
totalExercises,
|
||||||
disableTimer = false,
|
disableTimer = false,
|
||||||
@@ -68,9 +69,9 @@ export default function ModuleTitle({
|
|||||||
if (!isMultipleChoiceLevelExercise() && !userSolutions) return null;
|
if (!isMultipleChoiceLevelExercise() && !userSolutions) return null;
|
||||||
|
|
||||||
const currentExercise = (exam as LevelExam).parts[partIndex!].exercises[examExerciseIndex] as MultipleChoiceExercise;
|
const currentExercise = (exam as LevelExam).parts[partIndex!].exercises[examExerciseIndex] as MultipleChoiceExercise;
|
||||||
const userSolution = userSolutions!.find((x) => x.exercise == currentExercise.id)!;
|
const userSolution = userSolutions!.find((x) => x.exercise.toString() == currentExercise.id.toString())!;
|
||||||
const answeredQuestions = new Set(userSolution.solutions.map(sol => sol.question));
|
const answeredQuestions = new Set(userSolution.solutions.map(sol => sol.question.toString()));
|
||||||
const exerciseOffset = currentExercise.questions[0].id;
|
const exerciseOffset = Number(currentExercise.questions[0].id);
|
||||||
const lastExercise = exerciseOffset + (currentExercise.questions.length - 1);
|
const lastExercise = exerciseOffset + (currentExercise.questions.length - 1);
|
||||||
|
|
||||||
const getQuestionColor = (questionId: string, solution: string, userQuestionSolution: string | undefined) => {
|
const getQuestionColor = (questionId: string, solution: string, userQuestionSolution: string | undefined) => {
|
||||||
@@ -96,10 +97,10 @@ export default function ModuleTitle({
|
|||||||
<div className="grid grid-cols-5 gap-3 px-4 py-2">
|
<div className="grid grid-cols-5 gap-3 px-4 py-2">
|
||||||
{currentExercise.questions.map((_, index) => {
|
{currentExercise.questions.map((_, index) => {
|
||||||
const questionNumber = exerciseOffset + index;
|
const questionNumber = exerciseOffset + index;
|
||||||
const isAnswered = answeredQuestions.has(questionNumber);
|
const isAnswered = answeredQuestions.has(questionNumber.toString());
|
||||||
const solution = currentExercise.questions.find((x) => x.id == questionNumber)!.solution;
|
const solution = currentExercise.questions.find((x) => x.id.toString() == questionNumber.toString())!.solution;
|
||||||
|
|
||||||
const userQuestionSolution = currentExercise.userSolutions?.find((x) => x.question == questionNumber)?.option;
|
const userQuestionSolution = currentExercise.userSolutions?.find((x) => x.question.toString() == questionNumber.toString())?.option;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant={showSolutions ? "solid" : (isAnswered ? "solid" : "outline")}
|
variant={showSolutions ? "solid" : (isAnswered ? "solid" : "outline")}
|
||||||
@@ -144,7 +145,7 @@ export default function ModuleTitle({
|
|||||||
return (
|
return (
|
||||||
<div key={index} className="text-2xl font-semibold flex flex-col gap-2">
|
<div key={index} className="text-2xl font-semibold flex flex-col gap-2">
|
||||||
{partInstructions.split("\\n").map((line, lineIndex) => (
|
{partInstructions.split("\\n").map((line, lineIndex) => (
|
||||||
<span key={lineIndex}>{line}</span>
|
<span key={lineIndex} dangerouslySetInnerHTML={{__html: line.replace('that is not correct', 'that is <span class="font-bold"><u>not correct</u></span>')}}></span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -157,7 +158,7 @@ export default function ModuleTitle({
|
|||||||
<div className="w-full flex justify-between">
|
<div className="w-full flex justify-between">
|
||||||
<span className="text-base font-semibold">
|
<span className="text-base font-semibold">
|
||||||
{module === "level"
|
{module === "level"
|
||||||
? "Placement Test"
|
? (examLabel ? examLabel : "Placement Test")
|
||||||
: `${moduleLabels[module]} exam${label ? ` - ${label}` : ''}`
|
: `${moduleLabels[module]} exam${label ? ` - ${label}` : ''}`
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -40,61 +40,71 @@ export default function SessionCard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-mti-gray-anti-flash flex w-64 flex-col gap-3 rounded-xl border p-4 text-black">
|
<div className="border-mti-gray-anti-flash flex w-64 flex-col justify-between gap-3 rounded-xl border p-4 text-black">
|
||||||
<span className="flex gap-1">
|
<div className="flex flex-col gap-3">
|
||||||
<b>ID:</b>
|
<span className="flex gap-1">
|
||||||
{session.sessionId}
|
<b>ID:</b>
|
||||||
</span>
|
{session.sessionId}
|
||||||
<span className="flex gap-1">
|
</span>
|
||||||
<b>Date:</b>
|
<span className="flex gap-1">
|
||||||
{moment(session.date).format("DD/MM/YYYY - HH:mm")}
|
<b>Date:</b>
|
||||||
</span>
|
{moment(session.date).format("DD/MM/YYYY - HH:mm")}
|
||||||
<div className="flex w-full items-center justify-between">
|
</span>
|
||||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-center justify-center gap-2">
|
{session.assignment && (
|
||||||
{session.selectedModules.sort(sortByModuleName).map((module) => (
|
<span className="flex flex-col gap-0">
|
||||||
<div
|
<b>Assignment:</b>
|
||||||
key={module}
|
{session.assignment.name}
|
||||||
data-tip={capitalize(module)}
|
</span>
|
||||||
className={clsx(
|
)}
|
||||||
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
|
||||||
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="h-4 w-4" />}
|
|
||||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
|
||||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
|
||||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 w-full">
|
<div className="flex flex-col gap-3">
|
||||||
<button
|
<div className="flex w-full items-center justify-between">
|
||||||
onClick={async () => await loadSession(session)}
|
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-center justify-center gap-2">
|
||||||
disabled={isLoading}
|
{session.selectedModules.sort(sortByModuleName).map((module) => (
|
||||||
className="bg-mti-green-ultralight w-full hover:bg-mti-green-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
<div
|
||||||
{!isLoading && "Resume"}
|
key={module}
|
||||||
{isLoading && (
|
data-tip={capitalize(module)}
|
||||||
<div className="flex items-center justify-center">
|
className={clsx(
|
||||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||||
</div>
|
module === "reading" && "bg-ielts-reading",
|
||||||
)}
|
module === "listening" && "bg-ielts-listening",
|
||||||
</button>
|
module === "writing" && "bg-ielts-writing",
|
||||||
<button
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
onClick={deleteSession}
|
module === "level" && "bg-ielts-level",
|
||||||
disabled={isLoading}
|
)}>
|
||||||
className="bg-mti-red-ultralight w-full hover:bg-mti-red-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
{!isLoading && "Delete"}
|
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||||
{isLoading && (
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
<div className="flex items-center justify-center">
|
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||||
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</button>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 w-full">
|
||||||
|
<button
|
||||||
|
onClick={async () => await loadSession(session)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="bg-mti-green-ultralight w-full hover:bg-mti-green-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
||||||
|
{!isLoading && "Resume"}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={deleteSession}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="bg-mti-red-ultralight w-full hover:bg-mti-red-light rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
|
||||||
|
{!isLoading && "Delete"}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<BsArrowRepeat className="animate-spin text-white" size={25} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from "react";
|
|||||||
import {BsClock, BsXCircle} from "react-icons/bs";
|
import {BsClock, BsXCircle} from "react-icons/bs";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {Stat, User} from "@/interfaces/user";
|
import {Stat, User} from "@/interfaces/user";
|
||||||
import {Module} from "@/interfaces";
|
import {Module, Step} from "@/interfaces";
|
||||||
import ai_usage from "@/utils/ai.detection";
|
import ai_usage from "@/utils/ai.detection";
|
||||||
import {calculateBandScore} from "@/utils/score";
|
import {calculateBandScore} from "@/utils/score";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
@@ -77,6 +77,7 @@ interface StatsGridItemProps {
|
|||||||
assignments: Assignment[];
|
assignments: Assignment[];
|
||||||
users: User[];
|
users: User[];
|
||||||
training?: boolean;
|
training?: boolean;
|
||||||
|
gradingSystem?: Step[];
|
||||||
selectedTrainingExams?: string[];
|
selectedTrainingExams?: string[];
|
||||||
maxTrainingExams?: number;
|
maxTrainingExams?: number;
|
||||||
setSelectedTrainingExams?: React.Dispatch<React.SetStateAction<string[]>>;
|
setSelectedTrainingExams?: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
@@ -97,6 +98,7 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
|
|||||||
users,
|
users,
|
||||||
training,
|
training,
|
||||||
selectedTrainingExams,
|
selectedTrainingExams,
|
||||||
|
gradingSystem,
|
||||||
setSelectedTrainingExams,
|
setSelectedTrainingExams,
|
||||||
setExams,
|
setExams,
|
||||||
setShowSolutions,
|
setShowSolutions,
|
||||||
@@ -214,10 +216,14 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
<span className={textColor}>
|
{!!assignment && (assignment.released || assignment.released === undefined) && (
|
||||||
Level{" "}
|
<span className={textColor}>
|
||||||
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
Level{" "}
|
||||||
</span>
|
{(
|
||||||
|
aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length
|
||||||
|
).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{shouldRenderPDFIcon() && renderPdfIcon(session, textColor, textColor)}
|
{shouldRenderPDFIcon() && renderPdfIcon(session, textColor, textColor)}
|
||||||
</div>
|
</div>
|
||||||
{examNumber === undefined ? (
|
{examNumber === undefined ? (
|
||||||
@@ -242,9 +248,9 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
|
|||||||
|
|
||||||
<div className="w-full flex flex-col gap-1">
|
<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")}>
|
<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}) => (
|
{!!assignment &&
|
||||||
<ModuleBadge key={module} module={module} level={level} />
|
(assignment.released || assignment.released === undefined) &&
|
||||||
))}
|
aggregatedLevels.map(({module, level}) => <ModuleBadge key={module} module={module} level={level} />)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{assignment && (
|
{assignment && (
|
||||||
@@ -271,7 +277,11 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
|
|||||||
selectedTrainingExams.some((exam) => exam.includes(timestamp)) &&
|
selectedTrainingExams.some((exam) => exam.includes(timestamp)) &&
|
||||||
"border-2 border-slate-600",
|
"border-2 border-slate-600",
|
||||||
)}
|
)}
|
||||||
onClick={examNumber === undefined ? selectExam : undefined}
|
onClick={() => {
|
||||||
|
if (!!assignment && !assignment.released) return;
|
||||||
|
if (examNumber === undefined) return selectExam();
|
||||||
|
return;
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
...(width !== undefined && {width}),
|
...(width !== undefined && {width}),
|
||||||
...(height !== undefined && {height}),
|
...(height !== undefined && {height}),
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ interface Props {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
title?: string;
|
title?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
titleClassName?: string;
|
||||||
children?: ReactElement;
|
children?: ReactElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Modal({isOpen, title, className, onClose, children}: Props) {
|
export default function Modal({isOpen, title, className, titleClassName, onClose, children}: Props) {
|
||||||
return (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
<Dialog as="div" className="relative z-[200]" onClose={onClose}>
|
<Dialog as="div" className="relative z-[200]" onClose={onClose}>
|
||||||
@@ -41,7 +42,7 @@ export default function Modal({isOpen, title, className, onClose, children}: Pro
|
|||||||
className,
|
className,
|
||||||
)}>
|
)}>
|
||||||
{title && (
|
{title && (
|
||||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
<Dialog.Title as="h3" className={clsx(titleClassName ? titleClassName : "text-lg font-medium leading-6 text-gray-900")}>
|
||||||
{title}
|
{title}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,24 +1,34 @@
|
|||||||
|
import {Step} from "@/interfaces";
|
||||||
|
import {getGradingLabel, getLevelLabel} from "@/utils/score";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
|
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||||
|
|
||||||
const ModuleBadge: React.FC<{ module: string; level?: number }> = ({ module, level }) => (
|
const ModuleBadge: React.FC<{module: string; level?: number; gradingSystem?: Step[]; className?: string}> = ({
|
||||||
<div
|
module,
|
||||||
className={clsx(
|
level,
|
||||||
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
|
gradingSystem,
|
||||||
module === "reading" && "bg-ielts-reading",
|
className,
|
||||||
module === "listening" && "bg-ielts-listening",
|
}) => (
|
||||||
module === "writing" && "bg-ielts-writing",
|
<div
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
className={clsx(
|
||||||
module === "level" && "bg-ielts-level",
|
"flex gap-2 justify-center 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 === "reading" && <BsBook className="w-4 h-4" />}
|
module === "listening" && "bg-ielts-listening",
|
||||||
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
|
module === "writing" && "bg-ielts-writing",
|
||||||
{module === "writing" && <BsPen className="w-4 h-4" />}
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
|
module === "level" && "bg-ielts-level",
|
||||||
{module === "level" && <BsClipboard className="w-4 h-4" />}
|
className,
|
||||||
{/* do not switch to level && it will convert the 0.0 to 0*/}
|
)}>
|
||||||
{level !== undefined && (<span className="text-sm">{level.toFixed(1)}</span>)}
|
{module === "reading" && <BsBook className="w-4 h-4" />}
|
||||||
</div>
|
{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">{module === "level" && gradingSystem ? getGradingLabel(level, gradingSystem) : level.toFixed(1)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default ModuleBadge;
|
export default ModuleBadge;
|
||||||
|
|||||||
@@ -145,8 +145,10 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
|||||||
<Link href={disableNavigation ? "" : "/profile"} className="-md:hidden flex items-center justify-end gap-6">
|
<Link href={disableNavigation ? "" : "/profile"} className="-md:hidden flex items-center justify-end gap-6">
|
||||||
<img src={user.profilePicture} alt={user.name} className="h-10 w-10 rounded-full object-cover" />
|
<img src={user.profilePicture} alt={user.name} className="h-10 w-10 rounded-full object-cover" />
|
||||||
<span className="-md:hidden text-right">
|
<span className="-md:hidden text-right">
|
||||||
{user.type === "corporate" ? `${user.corporateInformation?.companyInformation.name} |` : ""} {user.name} |{" "}
|
{(user.type === "corporate" || user.type === "mastercorporate") && !!user.corporateInformation?.companyInformation?.name
|
||||||
{USER_TYPE_LABELS[user.type]}
|
? `${user.corporateInformation?.companyInformation.name} |`
|
||||||
|
: ""}{" "}
|
||||||
|
{user.name} | {USER_TYPE_LABELS[user.type]}
|
||||||
{user.type === "corporate" &&
|
{user.type === "corporate" &&
|
||||||
!!user.demographicInformation?.position &&
|
!!user.demographicInformation?.position &&
|
||||||
` | ${user.demographicInformation?.position || "N/A"}`}
|
` | ${user.demographicInformation?.position || "N/A"}`}
|
||||||
|
|||||||
@@ -1,27 +1,17 @@
|
|||||||
import { FillBlanksExercise, FillBlanksMCOption, ShuffleMap } from "@/interfaces/exam";
|
import {FillBlanksExercise, FillBlanksMCOption, ShuffleMap} 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";
|
import useExamStore from "@/stores/examStore";
|
||||||
|
|
||||||
export default function FillBlanksSolutions({
|
export default function FillBlanksSolutions({id, type, prompt, solutions, words, text, onNext, onBack}: FillBlanksExercise & CommonProps) {
|
||||||
id,
|
|
||||||
type,
|
|
||||||
prompt,
|
|
||||||
solutions,
|
|
||||||
words,
|
|
||||||
text,
|
|
||||||
onNext,
|
|
||||||
onBack,
|
|
||||||
}: FillBlanksExercise & CommonProps) {
|
|
||||||
const storeUserSolutions = useExamStore((state) => state.userSolutions);
|
const storeUserSolutions = useExamStore((state) => state.userSolutions);
|
||||||
|
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
|
||||||
|
|
||||||
|
const correctUserSolutions = storeUserSolutions.find((solution) => solution.exercise === id)?.solutions;
|
||||||
|
|
||||||
const correctUserSolutions = storeUserSolutions.find(
|
|
||||||
(solution) => solution.exercise === id
|
|
||||||
)?.solutions;
|
|
||||||
|
|
||||||
const shuffles = useExamStore((state) => state.shuffles);
|
const shuffles = useExamStore((state) => state.shuffles);
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
@@ -33,7 +23,7 @@ export default function FillBlanksSolutions({
|
|||||||
const option = words.find((w) => {
|
const option = words.find((w) => {
|
||||||
if (typeof w === "string") {
|
if (typeof w === "string") {
|
||||||
return w.toLowerCase() === x.solution.toLowerCase();
|
return w.toLowerCase() === x.solution.toLowerCase();
|
||||||
} else if ('letter' in w) {
|
} else if ("letter" in w) {
|
||||||
return w.letter.toLowerCase() === x.solution.toLowerCase();
|
return w.letter.toLowerCase() === x.solution.toLowerCase();
|
||||||
} else {
|
} else {
|
||||||
return w.id.toString() === x.id.toString();
|
return w.id.toString() === x.id.toString();
|
||||||
@@ -43,23 +33,20 @@ export default function FillBlanksSolutions({
|
|||||||
|
|
||||||
if (typeof option === "string") {
|
if (typeof option === "string") {
|
||||||
return solution.toLowerCase() === option.toLowerCase();
|
return solution.toLowerCase() === option.toLowerCase();
|
||||||
} else if ('letter' in option) {
|
} else if ("letter" in option) {
|
||||||
return solution.toLowerCase() === option.word.toLowerCase();
|
return solution.toLowerCase() === option.word.toLowerCase();
|
||||||
} else if ('options' in option) {
|
} else if ("options" in option) {
|
||||||
return option.options[solution as keyof typeof option.options] == x.solution;
|
return option.options[solution as keyof typeof option.options] == x.solution;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}).length;
|
}).length;
|
||||||
const missing = total - correctUserSolutions!.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[] => {
|
const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => {
|
||||||
return Array.isArray(words) && words.every(
|
return Array.isArray(words) && words.every((word) => word && typeof word === "object" && "id" in word && "options" in word);
|
||||||
word => word && typeof word === 'object' && 'id' in word && 'options' in word
|
};
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderLines = (line: string) => {
|
const renderLines = (line: string) => {
|
||||||
return (
|
return (
|
||||||
@@ -67,17 +54,17 @@ export default function FillBlanksSolutions({
|
|||||||
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
{reactStringReplace(line, /({{\d+}})/g, (match) => {
|
||||||
const questionId = match.replaceAll(/[\{\}]/g, "");
|
const questionId = match.replaceAll(/[\{\}]/g, "");
|
||||||
const userSolution = correctUserSolutions!.find((x) => x.id.toString() === questionId.toString());
|
const userSolution = correctUserSolutions!.find((x) => x.id.toString() === questionId.toString());
|
||||||
const answerSolution = solutions.find(sol => sol.id.toString() === questionId.toString())!.solution;
|
const answerSolution = solutions.find((sol) => sol.id.toString() === questionId.toString())!.solution;
|
||||||
const questionShuffleMap = shuffles.find((x) => x.exerciseID == id)?.shuffles.find((y) => y.questionID == questionId);
|
const questionShuffleMap = shuffles.find((x) => x.exerciseID == id)?.shuffles.find((y) => y.questionID == questionId);
|
||||||
const newAnswerSolution = questionShuffleMap ? questionShuffleMap.map[answerSolution].toLowerCase() : answerSolution.toLowerCase();
|
const newAnswerSolution = questionShuffleMap
|
||||||
|
? questionShuffleMap.map[answerSolution].toLowerCase()
|
||||||
|
: answerSolution.toLowerCase();
|
||||||
|
|
||||||
if (!userSolution) {
|
if (!userSolution) {
|
||||||
let answerText;
|
let answerText;
|
||||||
if (typeCheckWordsMC(words)) {
|
if (typeCheckWordsMC(words)) {
|
||||||
const options = words.find((x) => x.id.toString() === questionId.toString());
|
const options = words.find((x) => x.id.toString() === questionId.toString());
|
||||||
const correctKey = Object.keys(options!.options).find(key =>
|
const correctKey = Object.keys(options!.options).find((key) => key.toLowerCase() === newAnswerSolution);
|
||||||
key.toLowerCase() === newAnswerSolution
|
|
||||||
);
|
|
||||||
answerText = options!.options[correctKey as keyof typeof options];
|
answerText = options!.options[correctKey as keyof typeof options];
|
||||||
} else {
|
} else {
|
||||||
answerText = answerSolution;
|
answerText = answerSolution;
|
||||||
@@ -96,37 +83,34 @@ 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()
|
||||||
: 'letter' in w
|
: "letter" in w
|
||||||
? w.letter.toLowerCase() === userSolution.solution.toLowerCase()
|
? w.letter.toLowerCase() === userSolution.solution.toLowerCase()
|
||||||
: 'options' in w
|
: "options" in w
|
||||||
? w.id === userSolution.questionId
|
? w.id === userSolution.questionId
|
||||||
: false
|
: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
const userSolutionText =
|
const userSolutionText =
|
||||||
typeof userSolutionWord === "string"
|
typeof userSolutionWord === "string"
|
||||||
? userSolutionWord
|
? userSolutionWord
|
||||||
: userSolutionWord && 'letter' in userSolutionWord
|
: userSolutionWord && "letter" in userSolutionWord
|
||||||
? userSolutionWord.word
|
? userSolutionWord.word
|
||||||
: userSolutionWord && 'options' in userSolutionWord
|
: userSolutionWord && "options" in userSolutionWord
|
||||||
? userSolution.solution
|
? userSolution.solution
|
||||||
: userSolution.solution;
|
: userSolution.solution;
|
||||||
|
|
||||||
let correct;
|
let correct;
|
||||||
let solutionText;
|
let solutionText;
|
||||||
if (typeCheckWordsMC(words)) {
|
if (typeCheckWordsMC(words)) {
|
||||||
const options = words.find((x) => x.id.toString() === questionId.toString());
|
const options = words.find((x) => x.id.toString() === questionId.toString());
|
||||||
if (options) {
|
if (options) {
|
||||||
const correctKey = Object.keys(options.options).find(key =>
|
const correctKey = Object.keys(options.options).find((key) => key.toLowerCase() === newAnswerSolution);
|
||||||
key.toLowerCase() === newAnswerSolution
|
|
||||||
);
|
|
||||||
correct = userSolution.solution == options.options[correctKey as keyof typeof options.options];
|
correct = userSolution.solution == options.options[correctKey as keyof typeof options.options];
|
||||||
solutionText = options.options[correctKey as keyof typeof options.options] || answerSolution;
|
solutionText = options.options[correctKey as keyof typeof options.options] || answerSolution;
|
||||||
} else {
|
} else {
|
||||||
correct = false;
|
correct = false;
|
||||||
solutionText = answerSolution;
|
solutionText = answerSolution;
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
correct = userSolutionText === answerSolution;
|
correct = userSolutionText === answerSolution;
|
||||||
solutionText = answerSolution;
|
solutionText = answerSolution;
|
||||||
@@ -169,7 +153,25 @@ export default function FillBlanksSolutions({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onBack({exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<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="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
|
||||||
{correctUserSolutions &&
|
{correctUserSolutions &&
|
||||||
@@ -200,18 +202,19 @@ export default function FillBlanksSolutions({
|
|||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onBack({ exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type })}
|
onClick={() => onBack({exercise: id, solutions: correctUserSolutions!, score: calculateScore(), type})}
|
||||||
className="max-w-[200px] w-full">
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
onClick={() => onNext({ exercise: id, solutions: correctUserSolutions!, 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>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -26,20 +26,24 @@ export default function InteractiveSpeaking({
|
|||||||
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
|
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
|
||||||
const [diffNumber, setDiffNumber] = useState(0);
|
const [diffNumber, setDiffNumber] = useState(0);
|
||||||
|
|
||||||
const tooltips: { [key: string]: string } = {
|
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.",
|
"Grammatical Range and Accuracy":
|
||||||
"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.",
|
"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.",
|
||||||
"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.",
|
"Fluency and Coherence":
|
||||||
"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.",
|
"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;
|
||||||
@@ -51,7 +55,41 @@ export default function InteractiveSpeaking({
|
|||||||
}, [userSolutions]);
|
}, [userSolutions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||||
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
onBack({
|
||||||
|
exercise: id,
|
||||||
|
solutions: userSolutions,
|
||||||
|
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() =>
|
||||||
|
onNext({
|
||||||
|
exercise: id,
|
||||||
|
solutions: userSolutions,
|
||||||
|
score: {
|
||||||
|
total: 100,
|
||||||
|
missing: 0,
|
||||||
|
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
||||||
|
},
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Modal title={`Correction (Prompt ${diffNumber})`} isOpen={diffNumber !== 0} onClose={() => setDiffNumber(0)}>
|
<Modal title={`Correction (Prompt ${diffNumber})`} isOpen={diffNumber !== 0} onClose={() => setDiffNumber(0)}>
|
||||||
<>
|
<>
|
||||||
{userSolutions &&
|
{userSolutions &&
|
||||||
@@ -71,13 +109,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")}
|
||||||
@@ -122,13 +160,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)}`] &&
|
userSolutions[0].evaluation[`transcript_${index + 1}`] &&
|
||||||
userSolutions[0].evaluation[`fixed_text_${(index + 1)}`] && (
|
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))}>
|
onClick={() => setDiffNumber(index + 1)}>
|
||||||
View Correction
|
View Correction
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -144,20 +182,24 @@ export default function InteractiveSpeaking({
|
|||||||
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 tooltip tooltip-bottom",
|
<div
|
||||||
index === 0 && "tooltip-right"
|
className={clsx(
|
||||||
)} key={key} data-tip={tooltips[key] || "No additional information available"}>
|
"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 > 0 ? (
|
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(
|
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",
|
||||||
@@ -168,7 +210,7 @@ export default function InteractiveSpeaking({
|
|||||||
General Feedback
|
General Feedback
|
||||||
</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",
|
||||||
@@ -178,20 +220,26 @@ export default function InteractiveSpeaking({
|
|||||||
}>
|
}>
|
||||||
Evaluation
|
Evaluation
|
||||||
</Tab>
|
</Tab>
|
||||||
{Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).map((key, index) => (
|
{Object.keys(userSolutions[0].evaluation)
|
||||||
<Tab
|
.filter((x) => x.startsWith("perfect_answer"))
|
||||||
key={key}
|
.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
|
||||||
Recommended Answer<br />(Prompt {index + 1})
|
? "bg-white shadow"
|
||||||
</Tab>
|
: "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
|
||||||
))}
|
)
|
||||||
|
}>
|
||||||
|
Recommended Answer
|
||||||
|
<br />
|
||||||
|
(Prompt {index + 1})
|
||||||
|
</Tab>
|
||||||
|
))}
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels>
|
<Tab.Panels>
|
||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
@@ -202,10 +250,16 @@ export default function InteractiveSpeaking({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} className="flex flex-col gap-2">
|
<div key={key} className="flex flex-col gap-2">
|
||||||
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit")} key={key}>
|
<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}
|
||||||
</div>
|
</div>
|
||||||
{typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>}
|
{typeof taskResponse !== "number" && (
|
||||||
|
<span className="px-2 py-2">{taskResponse.comment}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -214,13 +268,18 @@ export default function InteractiveSpeaking({
|
|||||||
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
{Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).map((key, index) => (
|
{Object.keys(userSolutions[0].evaluation)
|
||||||
<Tab.Panel key={key} className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
.filter((x) => x.startsWith("perfect_answer"))
|
||||||
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
.map((key, index) => (
|
||||||
{userSolutions[0].evaluation![`perfect_answer_${(index + 1)}`].answer.replaceAll(/\s{2,}/g, "\n\n")}
|
<Tab.Panel key={key} className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
|
||||||
</span>
|
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
|
||||||
</Tab.Panel>
|
{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>
|
||||||
) : (
|
) : (
|
||||||
@@ -241,7 +300,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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -266,6 +325,6 @@ export default function InteractiveSpeaking({
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Icon from "@mdi/react";
|
|||||||
import {Fragment} from "react";
|
import {Fragment} from "react";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
import Xarrow from "react-xarrows";
|
import Xarrow from "react-xarrows";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
|
||||||
function QuestionSolutionArea({
|
function QuestionSolutionArea({
|
||||||
question,
|
question,
|
||||||
@@ -61,6 +62,8 @@ export default function MatchSentencesSolutions({
|
|||||||
onNext,
|
onNext,
|
||||||
onBack,
|
onBack,
|
||||||
}: MatchSentencesExercise & CommonProps) {
|
}: MatchSentencesExercise & CommonProps) {
|
||||||
|
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = sentences.length;
|
const total = sentences.length;
|
||||||
const correct = userSolutions.filter(
|
const correct = userSolutions.filter(
|
||||||
@@ -72,7 +75,25 @@ export default function MatchSentencesSolutions({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<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">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
@@ -112,7 +133,8 @@ export default function MatchSentencesSolutions({
|
|||||||
color="purple"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
className="max-w-[200px] w-full">
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -123,6 +145,6 @@ export default function MatchSentencesSolutions({
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,10 +111,10 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
|
|||||||
};
|
};
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
if (questionIndex === questions.length - 1) {
|
if (questionIndex + 1 >= 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 + 2);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -122,22 +122,49 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
|
|||||||
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 - 2);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-4 w-full h-full mb-20">
|
<div className="flex justify-between w-full gap-8">
|
||||||
<div className="flex flex-col gap-2 mt-4 h-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
<Button
|
||||||
{/*<span className="text-xl font-semibold">{prompt}</span>*/}
|
color="purple"
|
||||||
{userSolutions && questionIndex < questions.length && (
|
variant="outline"
|
||||||
<Question
|
onClick={back}
|
||||||
{...questions[questionIndex]}
|
className="max-w-[200px] w-full"
|
||||||
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
/>
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button color="purple" onClick={next} className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 w-full h-full mb-20 mt-4">
|
||||||
|
<div className="flex flex-col gap-4 mt-2">
|
||||||
|
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
|
{/*<span className="text-xl font-semibold">{prompt}</span>*/}
|
||||||
|
{userSolutions && questionIndex < questions.length && (
|
||||||
|
<Question
|
||||||
|
{...questions[questionIndex]}
|
||||||
|
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{userSolutions && questionIndex + 1 < questions.length && (
|
||||||
|
<div className="flex flex-col gap-8 h-fit w-full bg-mti-gray-smoke rounded-xl px-16 py-8">
|
||||||
|
<Question
|
||||||
|
{...questions[questionIndex + 1]}
|
||||||
|
userSolution={userSolutions.find((x) => questions[questionIndex + 1].id === x.question)?.option}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="w-4 h-4 rounded-full bg-mti-purple" />
|
<div className="w-4 h-4 rounded-full bg-mti-purple" />
|
||||||
@@ -160,14 +187,7 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={back}
|
onClick={back}
|
||||||
className="max-w-[200px] w-full"
|
className="max-w-[200px] w-full"
|
||||||
disabled={
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
exam &&
|
|
||||||
typeof partIndex !== "undefined" &&
|
|
||||||
exam.module === "level" &&
|
|
||||||
typeof exam.parts[0].intro === "string" &&
|
|
||||||
questionIndex === 0 &&
|
|
||||||
partIndex === 0
|
|
||||||
}>
|
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -175,6 +195,6 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, us
|
|||||||
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,15 +32,53 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
|
|||||||
}
|
}
|
||||||
}, [userSolutions]);
|
}, [userSolutions]);
|
||||||
|
|
||||||
const tooltips: { [key: string]: string } = {
|
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.",
|
"Grammatical Range and Accuracy":
|
||||||
"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.",
|
"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.",
|
||||||
"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.",
|
"Fluency and Coherence":
|
||||||
"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.",
|
"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 (
|
||||||
<>
|
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||||
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
onBack({
|
||||||
|
exercise: id,
|
||||||
|
solutions: userSolutions,
|
||||||
|
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() =>
|
||||||
|
onNext({
|
||||||
|
exercise: id,
|
||||||
|
solutions: userSolutions,
|
||||||
|
score: {
|
||||||
|
total: 100,
|
||||||
|
missing: 0,
|
||||||
|
correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
||||||
|
},
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}>
|
<Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}>
|
||||||
<>
|
<>
|
||||||
{userSolutions &&
|
{userSolutions &&
|
||||||
@@ -58,13 +96,13 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
|
|||||||
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")}
|
||||||
@@ -138,20 +176,24 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
|
|||||||
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 tooltip tooltip-bottom",
|
<div
|
||||||
index === 0 && "tooltip-right"
|
className={clsx(
|
||||||
)} key={key} data-tip={tooltips[key] || "No additional information available"}>
|
"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(
|
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",
|
||||||
@@ -162,7 +204,7 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
|
|||||||
General Feedback
|
General Feedback
|
||||||
</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",
|
||||||
@@ -173,7 +215,7 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
|
|||||||
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",
|
||||||
@@ -194,10 +236,16 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} className="flex flex-col gap-2">
|
<div key={key} className="flex flex-col gap-2">
|
||||||
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit")} key={key}>
|
<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}
|
||||||
</div>
|
</div>
|
||||||
{typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>}
|
{typeof taskResponse !== "number" && (
|
||||||
|
<span className="px-2 py-2">{taskResponse.comment}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -236,7 +284,7 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
|
|||||||
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -261,6 +309,6 @@ export default function Speaking({ id, type, title, video_url, text, prompts, us
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ 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";
|
||||||
|
|
||||||
type Solution = "true" | "false" | "not_given";
|
type Solution = "true" | "false" | "not_given";
|
||||||
|
|
||||||
export default function TrueFalseSolution({prompt, type, id, questions, userSolutions, onNext, onBack}: TrueFalseExercise & CommonProps) {
|
export default function TrueFalseSolution({prompt, type, id, questions, userSolutions, onNext, onBack}: TrueFalseExercise & CommonProps) {
|
||||||
|
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = questions.length || 0;
|
const total = questions.length || 0;
|
||||||
const correct = userSolutions.filter(
|
const correct = userSolutions.filter(
|
||||||
@@ -37,7 +40,25 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<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">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
@@ -121,7 +142,8 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
|||||||
color="purple"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
className="max-w-[200px] w-full">
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -132,6 +154,6 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import reactStringReplace from "react-string-replace";
|
|||||||
import {CommonProps} from ".";
|
import {CommonProps} from ".";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import Button from "../Low/Button";
|
import Button from "../Low/Button";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
|
||||||
function Blank({
|
function Blank({
|
||||||
id,
|
id,
|
||||||
@@ -71,6 +72,8 @@ export default function WriteBlanksSolutions({
|
|||||||
onNext,
|
onNext,
|
||||||
onBack,
|
onBack,
|
||||||
}: WriteBlanksExercise & CommonProps) {
|
}: WriteBlanksExercise & CommonProps) {
|
||||||
|
const {questionIndex, setQuestionIndex, partIndex, exam} = useExamStore((state) => state);
|
||||||
|
|
||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = text.match(/({{\d+}})/g)?.length || 0;
|
const total = text.match(/({{\d+}})/g)?.length || 0;
|
||||||
const correct = userSolutions.filter(
|
const correct = userSolutions.filter(
|
||||||
@@ -102,7 +105,25 @@ export default function WriteBlanksSolutions({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex justify-between w-full gap-8">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() => onNext({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<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">
|
<span className="text-sm w-full leading-6">
|
||||||
{prompt.split("\\n").map((line, index) => (
|
{prompt.split("\\n").map((line, index) => (
|
||||||
@@ -142,7 +163,8 @@ export default function WriteBlanksSolutions({
|
|||||||
color="purple"
|
color="purple"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
onClick={() => onBack({exercise: id, solutions: userSolutions, score: calculateScore(), type})}
|
||||||
className="max-w-[200px] w-full">
|
className="max-w-[200px] w-full"
|
||||||
|
disabled={exam && typeof partIndex !== "undefined" && exam.module === "level" && questionIndex === 0 && partIndex === 0}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -153,6 +175,6 @@ export default function WriteBlanksSolutions({
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,70 @@
|
|||||||
/* 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 } = {
|
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.",
|
"Lexical Resource":
|
||||||
"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.",
|
"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.",
|
||||||
"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.",
|
"Task Achievement":
|
||||||
"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.",
|
"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 (
|
||||||
<>
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
|
<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: userSolutions,
|
||||||
|
score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onClick={() =>
|
||||||
|
onNext({
|
||||||
|
exercise: id,
|
||||||
|
solutions: userSolutions,
|
||||||
|
score: {
|
||||||
|
total: 100,
|
||||||
|
missing: 0,
|
||||||
|
correct: userSolutions[0]?.evaluation ? writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0,
|
||||||
|
},
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="max-w-[200px] self-end w-full">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{attachment && (
|
{attachment && (
|
||||||
<Transition show={isModalOpen} as={Fragment}>
|
<Transition show={isModalOpen} as={Fragment}>
|
||||||
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
<Dialog onClose={() => setIsModalOpen(false)} className="relative z-50">
|
||||||
@@ -99,13 +137,13 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
|
|||||||
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")}
|
||||||
@@ -135,10 +173,13 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
|
|||||||
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx(
|
<div
|
||||||
"bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
|
className={clsx(
|
||||||
index === 0 && "tooltip-right"
|
"bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
|
||||||
)} key={key} data-tip={tooltips[key] || "No additional information available"}>
|
index === 0 && "tooltip-right",
|
||||||
|
)}
|
||||||
|
key={key}
|
||||||
|
data-tip={tooltips[key] || "No additional information available"}>
|
||||||
{key}: Level {grade}
|
{key}: Level {grade}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -148,7 +189,7 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
|
|||||||
<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(
|
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,7 +200,7 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
|
|||||||
General Feedback
|
General Feedback
|
||||||
</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",
|
||||||
@@ -170,7 +211,7 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
|
|||||||
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",
|
||||||
@@ -182,7 +223,7 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
|
|||||||
</Tab>
|
</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",
|
||||||
@@ -204,10 +245,16 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} className="flex flex-col gap-2">
|
<div key={key} className="flex flex-col gap-2">
|
||||||
<div className={clsx("bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 w-fit")} key={key}>
|
<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}
|
||||||
</div>
|
</div>
|
||||||
{typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>}
|
{typeof taskResponse !== "number" && (
|
||||||
|
<span className="px-2 py-2">{taskResponse.comment}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -248,7 +295,7 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
|
|||||||
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -273,6 +320,6 @@ export default function Writing({ id, type, prompt, attachment, userSolutions, o
|
|||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import {CorporateInformation, CorporateUser, EMPLOYMENT_STATUS, User, Type, Stat} from "@/interfaces/user";
|
import {CorporateInformation, CorporateUser, EMPLOYMENT_STATUS, User, Type, Stat, Gender} from "@/interfaces/user";
|
||||||
import {groupBySession, averageScore} from "@/utils/stats";
|
import {groupBySession, averageScore} from "@/utils/stats";
|
||||||
import {RadioGroup} from "@headlessui/react";
|
import {RadioGroup} from "@headlessui/react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -92,6 +92,9 @@ const UserCard = ({
|
|||||||
user.type === "corporate" || user.type === "mastercorporate" ? user.demographicInformation?.position : undefined,
|
user.type === "corporate" || user.type === "mastercorporate" ? user.demographicInformation?.position : undefined,
|
||||||
);
|
);
|
||||||
const [studentID, setStudentID] = useState<string | undefined>(user.type === "student" ? user.studentID : undefined);
|
const [studentID, setStudentID] = useState<string | undefined>(user.type === "student" ? user.studentID : undefined);
|
||||||
|
const [name, setName] = useState<string>(user.name);
|
||||||
|
const [phone, setPhone] = useState<string | undefined>(user.demographicInformation?.phone);
|
||||||
|
const [gender, setGender] = useState<Gender | undefined>(user.demographicInformation?.gender);
|
||||||
|
|
||||||
const [referralAgent, setReferralAgent] = useState(
|
const [referralAgent, setReferralAgent] = useState(
|
||||||
user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation?.referralAgent : undefined,
|
user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation?.referralAgent : undefined,
|
||||||
@@ -140,7 +143,11 @@ const UserCard = ({
|
|||||||
}, [users, referralAgent]);
|
}, [users, referralAgent]);
|
||||||
|
|
||||||
const updateUser = () => {
|
const updateUser = () => {
|
||||||
if ((user.type === "corporate" || user.type === "mastercorporate") && (!paymentValue || paymentValue < 0))
|
if (
|
||||||
|
(user.type === "corporate" || user.type === "mastercorporate") &&
|
||||||
|
(!paymentValue || paymentValue < 0) &&
|
||||||
|
["admin", "developer"].includes(loggedInUser.type)
|
||||||
|
)
|
||||||
return toast.error("Please set a price for the user's package before updating!");
|
return toast.error("Please set a price for the user's package before updating!");
|
||||||
|
|
||||||
if (!confirm(`Are you sure you want to update ${user.name}'s account?`)) return;
|
if (!confirm(`Are you sure you want to update ${user.name}'s account?`)) return;
|
||||||
@@ -152,6 +159,11 @@ const UserCard = ({
|
|||||||
studentID,
|
studentID,
|
||||||
type,
|
type,
|
||||||
status,
|
status,
|
||||||
|
name,
|
||||||
|
demographicInformation: {
|
||||||
|
...(!!user.demographicInformation ? user.demographicInformation : {}),
|
||||||
|
phone,
|
||||||
|
},
|
||||||
agentInformation:
|
agentInformation:
|
||||||
type === "agent"
|
type === "agent"
|
||||||
? {
|
? {
|
||||||
@@ -161,7 +173,7 @@ const UserCard = ({
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
corporateInformation:
|
corporateInformation:
|
||||||
type === "corporate"
|
type === "corporate" || type === "mastercorporate"
|
||||||
? {
|
? {
|
||||||
referralAgent,
|
referralAgent,
|
||||||
monthlyDuration,
|
monthlyDuration,
|
||||||
@@ -429,10 +441,10 @@ const UserCard = ({
|
|||||||
label="Name"
|
label="Name"
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
onChange={() => null}
|
onChange={setName}
|
||||||
placeholder="Enter your name"
|
placeholder="Enter your name"
|
||||||
defaultValue={user.name}
|
defaultValue={name}
|
||||||
disabled
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="E-mail Address"
|
label="E-mail Address"
|
||||||
@@ -454,10 +466,10 @@ const UserCard = ({
|
|||||||
type="tel"
|
type="tel"
|
||||||
name="phone"
|
name="phone"
|
||||||
label="Phone number"
|
label="Phone number"
|
||||||
onChange={() => null}
|
onChange={setPhone}
|
||||||
placeholder="Enter phone number"
|
placeholder="Enter phone number"
|
||||||
defaultValue={user.demographicInformation?.phone}
|
defaultValue={phone}
|
||||||
disabled
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -528,7 +540,8 @@ const UserCard = ({
|
|||||||
<div className="relative flex flex-col gap-3 w-full">
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Gender</label>
|
<label className="font-normal text-base text-mti-gray-dim">Gender</label>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={user.demographicInformation?.gender}
|
value={gender}
|
||||||
|
onChange={(e) => setGender(e)}
|
||||||
className="flex flex-row gap-4 justify-between"
|
className="flex flex-row gap-4 justify-between"
|
||||||
disabled={disabled}>
|
disabled={disabled}>
|
||||||
<RadioGroup.Option value="male">
|
<RadioGroup.Option value="male">
|
||||||
@@ -582,7 +595,9 @@ const UserCard = ({
|
|||||||
isChecked={!!expiryDate}
|
isChecked={!!expiryDate}
|
||||||
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : null)}
|
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : null)}
|
||||||
disabled={
|
disabled={
|
||||||
disabled || (!["admin", "developer"].includes(loggedInUser.type) && !!loggedInUser.subscriptionExpirationDate)
|
disabled ||
|
||||||
|
(!["admin", "developer", "mastercorporate", "corporate"].includes(loggedInUser.type) &&
|
||||||
|
!!loggedInUser.subscriptionExpirationDate)
|
||||||
}>
|
}>
|
||||||
Enabled
|
Enabled
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
|
|||||||
@@ -45,14 +45,13 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowModal(!!selectedUser && page === "");
|
setShowModal(!!selectedUser && router.asPath === "/#");
|
||||||
}, [selectedUser, page]);
|
}, [selectedUser, router.asPath]);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
useEffect(reload, [page]);
|
useEffect(reload, [page]);
|
||||||
|
|
||||||
const inactiveCountryManagerFilter = (x: User) =>
|
const inactiveCountryManagerFilter = (x: User) => x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate);
|
||||||
x.type === "agent" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
|
||||||
|
|
||||||
const UserDisplay = (displayUser: User) => (
|
const UserDisplay = (displayUser: User) => (
|
||||||
<div
|
<div
|
||||||
@@ -72,22 +71,22 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
|
|
||||||
const StudentsList = () => {
|
const StudentsList = () => {
|
||||||
const filter = (x: User) =>
|
const filter = (x: User) =>
|
||||||
x.type === "student" &&
|
!!selectedUser
|
||||||
(!!selectedUser
|
|
||||||
? groups
|
? groups
|
||||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
.flatMap((g) => g.participants)
|
.flatMap((g) => g.participants)
|
||||||
.includes(x.id)
|
.includes(x.id)
|
||||||
: true);
|
: true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserList
|
<UserList
|
||||||
user={user}
|
user={user}
|
||||||
|
type="student"
|
||||||
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={() => router.push("/")}
|
||||||
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>
|
||||||
@@ -101,22 +100,22 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
|
|
||||||
const TeachersList = () => {
|
const TeachersList = () => {
|
||||||
const filter = (x: User) =>
|
const filter = (x: User) =>
|
||||||
x.type === "teacher" &&
|
!!selectedUser
|
||||||
(!!selectedUser
|
|
||||||
? groups
|
? groups
|
||||||
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
.flatMap((g) => g.participants)
|
.flatMap((g) => g.participants)
|
||||||
.includes(x.id) || false
|
.includes(x.id) || false
|
||||||
: true);
|
: true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserList
|
<UserList
|
||||||
user={user}
|
user={user}
|
||||||
|
type="teacher"
|
||||||
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={() => router.push("/")}
|
||||||
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>
|
||||||
@@ -129,16 +128,14 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AgentsList = () => {
|
const AgentsList = () => {
|
||||||
const filter = (x: User) => x.type === "agent";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserList
|
<UserList
|
||||||
user={user}
|
user={user}
|
||||||
filters={[filter]}
|
type="agent"
|
||||||
renderHeader={(total) => (
|
renderHeader={(total) => (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
onClick={() => router.push("/")}
|
||||||
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>
|
||||||
@@ -153,11 +150,11 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
const CorporateList = () => (
|
const CorporateList = () => (
|
||||||
<UserList
|
<UserList
|
||||||
user={user}
|
user={user}
|
||||||
filters={[(x) => x.type === "corporate"]}
|
type="corporate"
|
||||||
renderHeader={(total) => (
|
renderHeader={(total) => (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
onClick={() => router.push("/")}
|
||||||
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>
|
||||||
@@ -170,16 +167,17 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
|
|
||||||
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) => list.includes(x.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserList
|
<UserList
|
||||||
user={user}
|
user={user}
|
||||||
|
type="corporate"
|
||||||
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={() => router.push("/")}
|
||||||
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>
|
||||||
@@ -197,11 +195,12 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
return (
|
return (
|
||||||
<UserList
|
<UserList
|
||||||
user={user}
|
user={user}
|
||||||
|
type="agent"
|
||||||
filters={[inactiveCountryManagerFilter]}
|
filters={[inactiveCountryManagerFilter]}
|
||||||
renderHeader={(total) => (
|
renderHeader={(total) => (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
onClick={() => router.push("/")}
|
||||||
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>
|
||||||
@@ -214,16 +213,17 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const InactiveStudentsList = () => {
|
const InactiveStudentsList = () => {
|
||||||
const filter = (x: User) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
const filter = (x: User) => x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserList
|
<UserList
|
||||||
user={user}
|
user={user}
|
||||||
|
type="student"
|
||||||
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={() => router.push("/")}
|
||||||
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>
|
||||||
@@ -236,16 +236,17 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const InactiveCorporateList = () => {
|
const InactiveCorporateList = () => {
|
||||||
const filter = (x: User) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
|
const filter = (x: User) => x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserList
|
<UserList
|
||||||
user={user}
|
user={user}
|
||||||
filters={[filter]}
|
filters={[filter]}
|
||||||
|
type="corporate"
|
||||||
renderHeader={(total) => (
|
renderHeader={(total) => (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
onClick={() => router.push("/")}
|
||||||
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>
|
||||||
@@ -262,7 +263,7 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
onClick={() => router.push("/")}
|
||||||
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>
|
||||||
@@ -281,28 +282,28 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
Icon={BsPersonFill}
|
Icon={BsPersonFill}
|
||||||
label="Students"
|
label="Students"
|
||||||
value={users.filter((x) => x.type === "student").length}
|
value={users.filter((x) => x.type === "student").length}
|
||||||
onClick={() => setPage("students")}
|
onClick={() => router.push("/#students")}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsPencilSquare}
|
Icon={BsPencilSquare}
|
||||||
label="Teachers"
|
label="Teachers"
|
||||||
value={users.filter((x) => x.type === "teacher").length}
|
value={users.filter((x) => x.type === "teacher").length}
|
||||||
onClick={() => setPage("teachers")}
|
onClick={() => router.push("/#teachers")}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsBank}
|
Icon={BsBank}
|
||||||
label="Corporate"
|
label="Corporate"
|
||||||
value={users.filter((x) => x.type === "corporate").length}
|
value={users.filter((x) => x.type === "corporate").length}
|
||||||
onClick={() => setPage("corporate")}
|
onClick={() => router.push("/#corporate")}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsBriefcaseFill}
|
Icon={BsBriefcaseFill}
|
||||||
label="Country Managers"
|
label="Country Managers"
|
||||||
value={users.filter((x) => x.type === "agent").length}
|
value={users.filter((x) => x.type === "agent").length}
|
||||||
onClick={() => setPage("agents")}
|
onClick={() => router.push("/#agents")}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
@@ -312,7 +313,7 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("inactiveStudents")}
|
onClick={() => router.push("/#inactiveStudents")}
|
||||||
Icon={BsPersonFill}
|
Icon={BsPersonFill}
|
||||||
label="Inactive Students"
|
label="Inactive Students"
|
||||||
value={
|
value={
|
||||||
@@ -322,14 +323,14 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
color="rose"
|
color="rose"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("inactiveCountryManagers")}
|
onClick={() => router.push("/#inactiveCountryManagers")}
|
||||||
Icon={BsBriefcaseFill}
|
Icon={BsBriefcaseFill}
|
||||||
label="Inactive Country Managers"
|
label="Inactive Country Managers"
|
||||||
value={users.filter(inactiveCountryManagerFilter).length}
|
value={users.filter(inactiveCountryManagerFilter).length}
|
||||||
color="rose"
|
color="rose"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("inactiveCorporate")}
|
onClick={() => router.push("/#inactiveCorporate")}
|
||||||
Icon={BsBank}
|
Icon={BsBank}
|
||||||
label="Inactive Corporate"
|
label="Inactive Corporate"
|
||||||
value={
|
value={
|
||||||
@@ -338,9 +339,15 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
}
|
}
|
||||||
color="rose"
|
color="rose"
|
||||||
/>
|
/>
|
||||||
<IconCard onClick={() => setPage("paymentdone")} Icon={BsCurrencyDollar} label="Payment Done" value={done.length} color="purple" />
|
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("paymentpending")}
|
onClick={() => router.push("/#paymentdone")}
|
||||||
|
Icon={BsCurrencyDollar}
|
||||||
|
label="Payment Done"
|
||||||
|
value={done.length}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard
|
||||||
|
onClick={() => router.push("/#paymentpending")}
|
||||||
Icon={BsCurrencyDollar}
|
Icon={BsCurrencyDollar}
|
||||||
label="Pending Payment"
|
label="Pending Payment"
|
||||||
value={pending.length}
|
value={pending.length}
|
||||||
@@ -352,7 +359,12 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
label="Content Management System (CMS)"
|
label="Content Management System (CMS)"
|
||||||
color="green"
|
color="green"
|
||||||
/>
|
/>
|
||||||
<IconCard onClick={() => setPage("corporatestudentslevels")} Icon={BsPersonFill} label="Corporate Students Levels" color="purple" />
|
<IconCard
|
||||||
|
onClick={() => router.push("/#corporatestudentslevels")}
|
||||||
|
Icon={BsPersonFill}
|
||||||
|
label="Corporate Students Levels"
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
</section>
|
</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">
|
||||||
@@ -598,17 +610,17 @@ export default function AdminDashboard({user}: Props) {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
</Modal>
|
</Modal>
|
||||||
{page === "students" && <StudentsList />}
|
{router.asPath === "/#students" && <StudentsList />}
|
||||||
{page === "teachers" && <TeachersList />}
|
{router.asPath === "/#teachers" && <TeachersList />}
|
||||||
{page === "corporate" && <CorporateList />}
|
{router.asPath === "/#corporate" && <CorporateList />}
|
||||||
{page === "agents" && <AgentsList />}
|
{router.asPath === "/#agents" && <AgentsList />}
|
||||||
{page === "inactiveStudents" && <InactiveStudentsList />}
|
{router.asPath === "/#inactiveStudents" && <InactiveStudentsList />}
|
||||||
{page === "inactiveCorporate" && <InactiveCorporateList />}
|
{router.asPath === "/#inactiveCorporate" && <InactiveCorporateList />}
|
||||||
{page === "inactiveCountryManagers" && <InactiveCountryManagerList />}
|
{router.asPath === "/#inactiveCountryManagers" && <InactiveCountryManagerList />}
|
||||||
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
|
{router.asPath === "/#paymentdone" && <CorporatePaidStatusList paid={true} />}
|
||||||
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
|
{router.asPath === "/#paymentpending" && <CorporatePaidStatusList paid={false} />}
|
||||||
{page === "corporatestudentslevels" && <CorporateStudentsLevelsHelper />}
|
{router.asPath === "/#corporatestudentslevels" && <CorporateStudentsLevelsHelper />}
|
||||||
{page === "" && <DefaultDashboard />}
|
{router.asPath === "/" && <DefaultDashboard />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Input from "@/components/Low/Input";
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useMemo, useState} from "react";
|
||||||
import {BsBook, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
import {BsBook, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
||||||
import {generate} from "random-words";
|
import {generate} from "random-words";
|
||||||
import {capitalize} from "lodash";
|
import {capitalize} from "lodash";
|
||||||
@@ -25,14 +25,16 @@ import useExams from "@/hooks/useExams";
|
|||||||
interface Props {
|
interface Props {
|
||||||
isCreating: boolean;
|
isCreating: boolean;
|
||||||
users: User[];
|
users: User[];
|
||||||
|
user: User;
|
||||||
groups: Group[];
|
groups: Group[];
|
||||||
assignment?: Assignment;
|
assignment?: Assignment;
|
||||||
cancelCreation: () => void;
|
cancelCreation: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssignmentCreator({isCreating, assignment, groups, users, cancelCreation}: Props) {
|
export default function AssignmentCreator({isCreating, assignment, user, groups, users, cancelCreation}: Props) {
|
||||||
const [selectedModules, setSelectedModules] = useState<Module[]>(assignment?.exams.map((e) => e.module) || []);
|
const [selectedModules, setSelectedModules] = useState<Module[]>(assignment?.exams.map((e) => e.module) || []);
|
||||||
const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []);
|
const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []);
|
||||||
|
const [teachers, setTeachers] = useState<string[]>(!!assignment ? assignment.teachers || [] : [...(user.type === "teacher" ? [user.id] : [])]);
|
||||||
const [name, setName] = useState(
|
const [name, setName] = useState(
|
||||||
assignment?.name ||
|
assignment?.name ||
|
||||||
generate({
|
generate({
|
||||||
@@ -45,7 +47,8 @@ export default function AssignmentCreator({isCreating, assignment, groups, users
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [startDate, setStartDate] = useState<Date | null>(assignment ? moment(assignment.startDate).toDate() : new Date());
|
const [startDate, setStartDate] = useState<Date | null>(assignment ? moment(assignment.startDate).toDate() : moment().add(1, "hour").toDate());
|
||||||
|
|
||||||
const [endDate, setEndDate] = useState<Date | null>(
|
const [endDate, setEndDate] = useState<Date | null>(
|
||||||
assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(),
|
assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(),
|
||||||
);
|
);
|
||||||
@@ -53,11 +56,19 @@ export default function AssignmentCreator({isCreating, assignment, groups, users
|
|||||||
const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied");
|
const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied");
|
||||||
// creates a new exam for each assignee or just one exam for all assignees
|
// creates a new exam for each assignee or just one exam for all assignees
|
||||||
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
|
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
|
||||||
|
const [released, setReleased] = useState<boolean>(assignment?.released || false);
|
||||||
|
|
||||||
|
const [autoStart, setAutostart] = useState<boolean>(assignment?.autoStart || false);
|
||||||
|
const [autoStartDate, setAutoStartDate] = useState<Date | null>(assignment ? moment(assignment.autoStartDate).toDate() : new Date());
|
||||||
|
|
||||||
const [useRandomExams, setUseRandomExams] = useState(true);
|
const [useRandomExams, setUseRandomExams] = useState(true);
|
||||||
const [examIDs, setExamIDs] = useState<{id: string; module: Module}[]>([]);
|
const [examIDs, setExamIDs] = useState<{id: string; module: Module}[]>([]);
|
||||||
|
|
||||||
const {exams} = useExams();
|
const {exams} = useExams();
|
||||||
|
|
||||||
|
const userStudents = useMemo(() => users.filter((x) => x.type === "student"), [users]);
|
||||||
|
const userTeachers = useMemo(() => users.filter((x) => x.type === "teacher"), [users]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module)));
|
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module)));
|
||||||
}, [selectedModules]);
|
}, [selectedModules]);
|
||||||
@@ -71,6 +82,10 @@ export default function AssignmentCreator({isCreating, assignment, groups, users
|
|||||||
setAssignees((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id]));
|
setAssignees((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id]));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleTeacher = (user: User) => {
|
||||||
|
setTeachers((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id]));
|
||||||
|
};
|
||||||
|
|
||||||
const createAssignment = () => {
|
const createAssignment = () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
@@ -82,8 +97,12 @@ export default function AssignmentCreator({isCreating, assignment, groups, users
|
|||||||
endDate,
|
endDate,
|
||||||
selectedModules,
|
selectedModules,
|
||||||
generateMultiple,
|
generateMultiple,
|
||||||
|
teachers,
|
||||||
variant,
|
variant,
|
||||||
instructorGender,
|
instructorGender,
|
||||||
|
released,
|
||||||
|
autoStart,
|
||||||
|
autoStartDate,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
|
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
|
||||||
@@ -227,7 +246,7 @@ export default function AssignmentCreator({isCreating, assignment, groups, users
|
|||||||
|
|
||||||
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
|
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Start Date *</label>
|
<label className="font-normal text-base text-mti-gray-dim">Limit Start Date *</label>
|
||||||
<ReactDatePicker
|
<ReactDatePicker
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
@@ -258,6 +277,24 @@ export default function AssignmentCreator({isCreating, assignment, groups, users
|
|||||||
onChange={(date) => setEndDate(date)}
|
onChange={(date) => setEndDate(date)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{autoStart && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Automatic Start Date *</label>
|
||||||
|
<ReactDatePicker
|
||||||
|
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 z-10",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
)}
|
||||||
|
popperClassName="!z-20"
|
||||||
|
filterTime={(date) => moment(date).isSameOrAfter(new Date())}
|
||||||
|
dateFormat="dd/MM/yyyy HH:mm"
|
||||||
|
selected={autoStartDate}
|
||||||
|
showTimeSelect
|
||||||
|
onChange={(date) => setAutoStartDate(date)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedModules.includes("speaking") && (
|
{selectedModules.includes("speaking") && (
|
||||||
@@ -335,7 +372,7 @@ export default function AssignmentCreator({isCreating, assignment, groups, users
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap -md:justify-center gap-4">
|
<div className="flex flex-wrap -md:justify-center gap-4">
|
||||||
{users.map((user) => (
|
{userStudents.map((user) => (
|
||||||
<div
|
<div
|
||||||
onClick={() => toggleAssignee(user)}
|
onClick={() => toggleAssignee(user)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -366,13 +403,72 @@ export default function AssignmentCreator({isCreating, assignment, groups, users
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div className="flex flex-col gap-4 w-full items-end">
|
|
||||||
|
{user.type !== "teacher" && (
|
||||||
|
<section className="w-full flex flex-col gap-3">
|
||||||
|
<span className="font-semibold">Teachers ({teachers.length} selected)</span>
|
||||||
|
<div className="flex gap-4 overflow-x-scroll scrollbar-hide">
|
||||||
|
{groups.map((g) => (
|
||||||
|
<button
|
||||||
|
key={g.id}
|
||||||
|
onClick={() => {
|
||||||
|
const groupStudentIds = users.filter((u) => g.participants.includes(u.id)).map((u) => u.id);
|
||||||
|
if (groupStudentIds.every((u) => teachers.includes(u))) {
|
||||||
|
setTeachers((prev) => prev.filter((a) => !groupStudentIds.includes(a)));
|
||||||
|
} else {
|
||||||
|
setTeachers((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
users.filter((u) => g.participants.includes(u.id)).every((u) => teachers.includes(u.id)) &&
|
||||||
|
"!bg-mti-purple-light !text-white",
|
||||||
|
)}>
|
||||||
|
{g.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap -md:justify-center gap-4">
|
||||||
|
{userTeachers.map((user) => (
|
||||||
|
<div
|
||||||
|
onClick={() => toggleTeacher(user)}
|
||||||
|
className={clsx(
|
||||||
|
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
|
||||||
|
"transition ease-in-out duration-300",
|
||||||
|
teachers.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum",
|
||||||
|
)}
|
||||||
|
key={user.id}>
|
||||||
|
<span className="flex flex-col gap-0 justify-center">
|
||||||
|
<span className="font-semibold">{user.name}</span>
|
||||||
|
<span className="text-sm opacity-80">{user.email}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-mti-black/80 text-sm whitespace-pre-wrap mt-2">
|
||||||
|
Groups:{" "}
|
||||||
|
{groups
|
||||||
|
.filter((g) => g.participants.includes(user.id))
|
||||||
|
.map((g) => g.name)
|
||||||
|
.join(", ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-4 w-full items-end">
|
||||||
<Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
|
<Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
|
||||||
Full length exams
|
Full length exams
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
|
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
|
||||||
Generate different exams
|
Generate different exams
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
|
<Checkbox isChecked={released} onChange={() => setReleased((d) => !d)}>
|
||||||
|
Auto release results
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox isChecked={autoStart} onChange={() => setAutostart((d) => !d)}>
|
||||||
|
Auto start exam
|
||||||
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 w-full justify-end">
|
<div className="flex gap-4 w-full justify-end">
|
||||||
<Button className="w-full max-w-[200px]" variant="outline" onClick={cancelCreation} disabled={isLoading} isLoading={isLoading}>
|
<Button className="w-full max-w-[200px]" variant="outline" onClick={cancelCreation} disabled={isLoading} isLoading={isLoading}>
|
||||||
|
|||||||
@@ -2,329 +2,433 @@ import Button from "@/components/Low/Button";
|
|||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {Module} from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import { Assignment } from "@/interfaces/results";
|
||||||
import {Stat, User} from "@/interfaces/user";
|
import { Stat, 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 {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 { 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";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
import {
|
||||||
import {toast} from "react-toastify";
|
BsBook,
|
||||||
|
BsClipboard,
|
||||||
|
BsHeadphones,
|
||||||
|
BsMegaphone,
|
||||||
|
BsPen,
|
||||||
|
} from "react-icons/bs";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { futureAssignmentFilter } from "@/utils/assignments";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
assignment?: Assignment;
|
assignment?: Assignment;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssignmentView({isOpen, assignment, onClose}: Props) {
|
export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
||||||
const {users} = useUsers();
|
const { users } = useUsers();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
const setExams = useExamStore((state) => state.setExams);
|
||||||
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
||||||
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||||
|
|
||||||
const deleteAssignment = async () => {
|
const deleteAssignment = async () => {
|
||||||
if (!confirm("Are you sure you want to delete this assignment?")) return;
|
if (!confirm("Are you sure you want to delete this assignment?")) return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.delete(`/api/assignments/${assignment?.id}`)
|
.delete(`/api/assignments/${assignment?.id}`)
|
||||||
.then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`))
|
.then(() =>
|
||||||
.catch(() => toast.error("Something went wrong, please try again later."))
|
toast.success(
|
||||||
.finally(onClose);
|
`Successfully deleted the assignment "${assignment?.name}".`
|
||||||
};
|
)
|
||||||
|
)
|
||||||
|
.catch(() => toast.error("Something went wrong, please try again later."))
|
||||||
|
.finally(onClose);
|
||||||
|
};
|
||||||
|
|
||||||
const startAssignment = () => {
|
const startAssignment = () => {
|
||||||
if (assignment) {
|
if (assignment) {
|
||||||
axios
|
axios
|
||||||
.post(`/api/assignments/${assignment.id}/start`)
|
.post(`/api/assignments/${assignment.id}/start`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`The assignment "${assignment.name}" has been started successfully!`);
|
toast.success(
|
||||||
})
|
`The assignment "${assignment.name}" has been started successfully!`
|
||||||
.catch((e) => {
|
);
|
||||||
console.log(e);
|
})
|
||||||
toast.error("Something went wrong, please try again later!");
|
.catch((e) => {
|
||||||
});
|
console.log(e);
|
||||||
}
|
toast.error("Something went wrong, please try again later!");
|
||||||
};
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatTimestamp = (timestamp: string) => {
|
const formatTimestamp = (timestamp: string) => {
|
||||||
const date = moment(parseInt(timestamp));
|
const date = moment(parseInt(timestamp));
|
||||||
const formatter = "YYYY/MM/DD - HH:mm";
|
const formatter = "YYYY/MM/DD - HH:mm";
|
||||||
|
|
||||||
return date.format(formatter);
|
return date.format(formatter);
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateAverageModuleScore = (module: Module) => {
|
const calculateAverageModuleScore = (module: Module) => {
|
||||||
if (!assignment) return -1;
|
if (!assignment) return -1;
|
||||||
|
|
||||||
const resultModuleBandScores = assignment.results.map((r) => {
|
const resultModuleBandScores = assignment.results.map((r) => {
|
||||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||||
|
|
||||||
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
const correct = moduleStats.reduce(
|
||||||
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
(acc, curr) => acc + curr.score.correct,
|
||||||
return calculateBandScore(correct, total, module, r.type);
|
0
|
||||||
});
|
);
|
||||||
|
const total = moduleStats.reduce(
|
||||||
|
(acc, curr) => acc + curr.score.total,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return calculateBandScore(correct, total, module, r.type);
|
||||||
|
});
|
||||||
|
|
||||||
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
|
return resultModuleBandScores.length === 0
|
||||||
};
|
? -1
|
||||||
|
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
|
||||||
|
assignment.results.length;
|
||||||
|
};
|
||||||
|
|
||||||
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
const aggregateScoresByModule = (
|
||||||
const scores: {
|
stats: Stat[]
|
||||||
[key in Module]: {total: number; missing: number; correct: number};
|
): { module: Module; total: number; missing: number; correct: number }[] => {
|
||||||
} = {
|
const scores: {
|
||||||
reading: {
|
[key in Module]: { total: number; missing: number; correct: number };
|
||||||
total: 0,
|
} = {
|
||||||
correct: 0,
|
reading: {
|
||||||
missing: 0,
|
total: 0,
|
||||||
},
|
correct: 0,
|
||||||
listening: {
|
missing: 0,
|
||||||
total: 0,
|
},
|
||||||
correct: 0,
|
listening: {
|
||||||
missing: 0,
|
total: 0,
|
||||||
},
|
correct: 0,
|
||||||
writing: {
|
missing: 0,
|
||||||
total: 0,
|
},
|
||||||
correct: 0,
|
writing: {
|
||||||
missing: 0,
|
total: 0,
|
||||||
},
|
correct: 0,
|
||||||
speaking: {
|
missing: 0,
|
||||||
total: 0,
|
},
|
||||||
correct: 0,
|
speaking: {
|
||||||
missing: 0,
|
total: 0,
|
||||||
},
|
correct: 0,
|
||||||
level: {
|
missing: 0,
|
||||||
total: 0,
|
},
|
||||||
correct: 0,
|
level: {
|
||||||
missing: 0,
|
total: 0,
|
||||||
},
|
correct: 0,
|
||||||
};
|
missing: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
stats.forEach((x) => {
|
stats.forEach((x) => {
|
||||||
scores[x.module!] = {
|
scores[x.module!] = {
|
||||||
total: scores[x.module!].total + x.score.total,
|
total: scores[x.module!].total + x.score.total,
|
||||||
correct: scores[x.module!].correct + x.score.correct,
|
correct: scores[x.module!].correct + x.score.correct,
|
||||||
missing: scores[x.module!].missing + x.score.missing,
|
missing: scores[x.module!].missing + x.score.missing,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return Object.keys(scores)
|
return Object.keys(scores)
|
||||||
.filter((x) => scores[x as Module].total > 0)
|
.filter((x) => scores[x as Module].total > 0)
|
||||||
.map((x) => ({module: x as Module, ...scores[x as Module]}));
|
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
|
const customContent = (
|
||||||
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
stats: Stat[],
|
||||||
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
user: string,
|
||||||
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
focus: "academic" | "general"
|
||||||
|
) => {
|
||||||
|
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 aggregatedLevels = aggregatedScores.map((x) => ({
|
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||||
module: x.module,
|
module: x.module,
|
||||||
level: calculateBandScore(x.correct, x.total, x.module, focus),
|
level: calculateBandScore(x.correct, x.total, x.module, focus),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const timeSpent = stats[0].timeSpent;
|
const timeSpent = stats[0].timeSpent;
|
||||||
|
|
||||||
const selectExam = () => {
|
const selectExam = () => {
|
||||||
const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
|
const examPromises = uniqBy(stats, "exam").map((stat) =>
|
||||||
|
getExamById(stat.module, stat.exam)
|
||||||
|
);
|
||||||
|
|
||||||
Promise.all(examPromises).then((exams) => {
|
Promise.all(examPromises).then((exams) => {
|
||||||
if (exams.every((x) => !!x)) {
|
if (exams.every((x) => !!x)) {
|
||||||
setUserSolutions(convertToUserSolutions(stats));
|
setUserSolutions(convertToUserSolutions(stats));
|
||||||
setShowSolutions(true);
|
setShowSolutions(true);
|
||||||
setExams(exams.map((x) => x!).sort(sortByModule));
|
setExams(exams.map((x) => x!).sort(sortByModule));
|
||||||
setSelectedModules(
|
setSelectedModules(
|
||||||
exams
|
exams
|
||||||
.map((x) => x!)
|
.map((x) => x!)
|
||||||
.sort(sortByModule)
|
.sort(sortByModule)
|
||||||
.map((x) => x!.module),
|
.map((x) => x!.module)
|
||||||
);
|
);
|
||||||
router.push("/exercises");
|
router.push("/exercises");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
|
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
|
||||||
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
|
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
|
||||||
<span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
|
<span className="font-medium">
|
||||||
{timeSpent && (
|
{formatTimestamp(stats[0].date.toString())}
|
||||||
<>
|
</span>
|
||||||
<span className="md:hidden 2xl:flex">• </span>
|
{timeSpent && (
|
||||||
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
|
<>
|
||||||
</>
|
<span className="md:hidden 2xl:flex">• </span>
|
||||||
)}
|
<span className="text-sm">
|
||||||
</div>
|
{Math.floor(timeSpent / 60)} minutes
|
||||||
<span
|
</span>
|
||||||
className={clsx(
|
</>
|
||||||
correct / total >= 0.7 && "text-mti-purple",
|
)}
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
</div>
|
||||||
correct / total < 0.3 && "text-mti-rose",
|
<span
|
||||||
)}>
|
className={clsx(
|
||||||
Level{" "}
|
correct / total >= 0.7 && "text-mti-purple",
|
||||||
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||||
</span>
|
correct / total < 0.3 && "text-mti-rose"
|
||||||
</div>
|
)}
|
||||||
|
>
|
||||||
|
Level{" "}
|
||||||
|
{(
|
||||||
|
aggregatedLevels.reduce(
|
||||||
|
(accumulator, current) => accumulator + current.level,
|
||||||
|
0
|
||||||
|
) / aggregatedLevels.length
|
||||||
|
).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-1">
|
<div className="flex w-full flex-col gap-1">
|
||||||
<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">
|
||||||
{aggregatedLevels.map(({module, level}) => (
|
{aggregatedLevels.map(({ module, level }) => (
|
||||||
<div
|
<div
|
||||||
key={module}
|
key={module}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||||
module === "reading" && "bg-ielts-reading",
|
module === "reading" && "bg-ielts-reading",
|
||||||
module === "listening" && "bg-ielts-listening",
|
module === "listening" && "bg-ielts-listening",
|
||||||
module === "writing" && "bg-ielts-writing",
|
module === "writing" && "bg-ielts-writing",
|
||||||
module === "speaking" && "bg-ielts-speaking",
|
module === "speaking" && "bg-ielts-speaking",
|
||||||
module === "level" && "bg-ielts-level",
|
module === "level" && "bg-ielts-level"
|
||||||
)}>
|
)}
|
||||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
>
|
||||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||||
<span className="text-sm">{level.toFixed(1)}</span>
|
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||||
</div>
|
<span className="text-sm">{level.toFixed(1)}</span>
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span>
|
<span>
|
||||||
{(() => {
|
{(() => {
|
||||||
const student = users.find((u) => u.id === user);
|
const student = users.find((u) => u.id === user);
|
||||||
return `${student?.name} (${student?.email})`;
|
return `${student?.name} (${student?.email})`;
|
||||||
})()}
|
})()}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
key={user}
|
key={user}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
|
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
|
||||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
correct / total >= 0.3 &&
|
||||||
correct / total < 0.3 && "hover:border-mti-rose",
|
correct / total < 0.7 &&
|
||||||
)}
|
"hover:border-mti-red",
|
||||||
onClick={selectExam}
|
correct / total < 0.3 && "hover:border-mti-rose"
|
||||||
role="button">
|
)}
|
||||||
{content}
|
onClick={selectExam}
|
||||||
</div>
|
role="button"
|
||||||
<div
|
>
|
||||||
key={user}
|
{content}
|
||||||
className={clsx(
|
</div>
|
||||||
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
|
<div
|
||||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
key={user}
|
||||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
className={clsx(
|
||||||
correct / total < 0.3 && "hover:border-mti-rose",
|
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
|
||||||
)}
|
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||||
data-tip="Your screen size is too small to view previous exams."
|
correct / total >= 0.3 &&
|
||||||
role="button">
|
correct / total < 0.7 &&
|
||||||
{content}
|
"hover:border-mti-red",
|
||||||
</div>
|
correct / total < 0.3 && "hover:border-mti-rose"
|
||||||
</div>
|
)}
|
||||||
);
|
data-tip="Your screen size is too small to view previous exams."
|
||||||
};
|
role="button"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
const shouldRenderStart = () => {
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
|
if (assignment) {
|
||||||
<div className="mt-4 flex w-full flex-col gap-4">
|
if (futureAssignmentFilter(assignment)) {
|
||||||
<ProgressBar
|
return true;
|
||||||
color="purple"
|
}
|
||||||
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
}
|
||||||
className="h-6"
|
|
||||||
textClassName={
|
|
||||||
(assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"
|
|
||||||
}
|
|
||||||
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100}
|
|
||||||
/>
|
|
||||||
<div className="flex items-start gap-8">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span>
|
|
||||||
Assignees:{" "}
|
|
||||||
{users
|
|
||||||
.filter((u) => assignment?.assignees.includes(u.id))
|
|
||||||
.map((u) => `${u.name} (${u.email})`)
|
|
||||||
.join(", ")}
|
|
||||||
</span>
|
|
||||||
<span>Assigner: {getUserName(users.find((x) => x.id === assignment?.assigner))}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span className="text-xl font-bold">Average Scores</span>
|
|
||||||
<div className="-md:mt-2 flex w-full items-center gap-4">
|
|
||||||
{assignment &&
|
|
||||||
uniqBy(assignment.exams, (x) => x.module).map(({module}) => (
|
|
||||||
<div
|
|
||||||
data-tip={capitalize(module)}
|
|
||||||
key={module}
|
|
||||||
className={clsx(
|
|
||||||
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
|
||||||
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="h-4 w-4" />}
|
|
||||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
|
||||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
|
||||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
|
||||||
{calculateAverageModuleScore(module) > -1 && (
|
|
||||||
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span className="text-xl font-bold">
|
|
||||||
Results ({assignment?.results.length}/{assignment?.assignees.length})
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
{assignment && assignment?.results.length > 0 && (
|
|
||||||
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
|
|
||||||
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{assignment && assignment?.results.length === 0 && <span className="ml-1 font-semibold">No results yet...</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-4 w-full items-center justify-end">
|
return false;
|
||||||
{assignment && (assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && (
|
};
|
||||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={deleteAssignment}>
|
|
||||||
Delete
|
return (
|
||||||
</Button>
|
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
|
||||||
)}
|
<div className="mt-4 flex w-full flex-col gap-4">
|
||||||
{assignment && (assignment.results.length === 0 || moment().isAfter(moment(assignment.startDate))) && (
|
<ProgressBar
|
||||||
<Button variant="outline" color="green" className="w-full max-w-[200px]" onClick={startAssignment}>
|
color="purple"
|
||||||
Start
|
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
||||||
</Button>
|
className="h-6"
|
||||||
)}
|
textClassName={
|
||||||
<Button onClick={onClose} className="w-full max-w-[200px]">
|
(assignment?.results.length || 0) /
|
||||||
Close
|
(assignment?.assignees.length || 1) <
|
||||||
</Button>
|
0.5
|
||||||
</div>
|
? "!text-mti-gray-dim font-light"
|
||||||
</div>
|
: "text-white"
|
||||||
</Modal>
|
}
|
||||||
);
|
percentage={
|
||||||
|
((assignment?.results.length || 0) /
|
||||||
|
(assignment?.assignees.length || 1)) *
|
||||||
|
100
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex items-start gap-8">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span>
|
||||||
|
Assignees:{" "}
|
||||||
|
{users
|
||||||
|
.filter((u) => assignment?.assignees.includes(u.id))
|
||||||
|
.map((u) => `${u.name} (${u.email})`)
|
||||||
|
.join(", ")}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Assigner:{" "}
|
||||||
|
{getUserName(users.find((x) => x.id === assignment?.assigner))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-xl font-bold">Average Scores</span>
|
||||||
|
<div className="-md:mt-2 flex w-full items-center gap-4">
|
||||||
|
{assignment &&
|
||||||
|
uniqBy(assignment.exams, (x) => x.module).map(({ module }) => (
|
||||||
|
<div
|
||||||
|
data-tip={capitalize(module)}
|
||||||
|
key={module}
|
||||||
|
className={clsx(
|
||||||
|
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||||
|
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="h-4 w-4" />}
|
||||||
|
{module === "listening" && (
|
||||||
|
<BsHeadphones className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||||
|
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||||
|
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||||
|
{calculateAverageModuleScore(module) > -1 && (
|
||||||
|
<span className="text-sm">
|
||||||
|
{calculateAverageModuleScore(module).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-xl font-bold">
|
||||||
|
Results ({assignment?.results.length}/{assignment?.assignees.length}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
{assignment && assignment?.results.length > 0 && (
|
||||||
|
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
|
||||||
|
{assignment.results.map((r) =>
|
||||||
|
customContent(r.stats, r.user, r.type)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{assignment && assignment?.results.length === 0 && (
|
||||||
|
<span className="ml-1 font-semibold">No results yet...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 w-full items-center justify-end">
|
||||||
|
{assignment &&
|
||||||
|
(assignment.results.length === assignment.assignees.length ||
|
||||||
|
moment().isAfter(moment(assignment.endDate))) && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={deleteAssignment}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{/** if the assignment is not deemed as active yet, display start */}
|
||||||
|
{shouldRenderStart() && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="green"
|
||||||
|
className="w-full max-w-[200px]"
|
||||||
|
onClick={startAssignment}
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={onClose} className="w-full max-w-[200px]">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers, { userHashStudent, userHashTeacher, userHashCorporate} from "@/hooks/useUsers";
|
||||||
import {CorporateUser, Group, MasterCorporateUser, Stat, User} from "@/interfaces/user";
|
import {CorporateUser, Group, MasterCorporateUser, 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";
|
||||||
@@ -51,6 +51,7 @@ import List from "@/components/List";
|
|||||||
import {getUserCompanyName} from "@/resources/user";
|
import {getUserCompanyName} from "@/resources/user";
|
||||||
import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments";
|
import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments";
|
||||||
import useUserBalance from "@/hooks/useUserBalance";
|
import useUserBalance from "@/hooks/useUserBalance";
|
||||||
|
import AssignmentsPage from "./views/AssignmentsPage";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: CorporateUser;
|
user: CorporateUser;
|
||||||
@@ -155,20 +156,19 @@ const StudentPerformanceList = ({items, stats, users}: {items: StudentPerformanc
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export default function CorporateDashboard({user, linkedCorporate}: Props) {
|
export default function CorporateDashboard({user, linkedCorporate}: Props) {
|
||||||
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 [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
|
||||||
|
|
||||||
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
||||||
const {users, reload, isLoading} = useUsers();
|
|
||||||
const {codes} = useCodes(user.id);
|
|
||||||
const {groups} = useGroups({admin: user.id});
|
const {groups} = useGroups({admin: user.id});
|
||||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
|
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
|
||||||
const {balance} = useUserBalance();
|
const {balance} = useUserBalance();
|
||||||
|
|
||||||
|
const {users: students, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(userHashStudent);
|
||||||
|
const {users: teachers, reload: reloadTeachers, isLoading: isTeachersLoading} = useUsers(userHashTeacher);
|
||||||
|
|
||||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -176,25 +176,20 @@ export default function CorporateDashboard({user, linkedCorporate}: Props) {
|
|||||||
|
|
||||||
const assignmentsUsers = useMemo(
|
const assignmentsUsers = useMemo(
|
||||||
() =>
|
() =>
|
||||||
users.filter(
|
[...teachers, ...students].filter((x) =>
|
||||||
(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) || false
|
||||||
.flatMap((g) => g.participants)
|
: groups.flatMap((g) => g.participants).includes(x.id),
|
||||||
.includes(x.id) || false
|
|
||||||
: groups.flatMap((g) => g.participants).includes(x.id)),
|
|
||||||
),
|
),
|
||||||
[groups, users, selectedUser],
|
[groups, teachers, students, selectedUser],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowModal(!!selectedUser && page === "");
|
setShowModal(!!selectedUser && router.asPath === "/#");
|
||||||
}, [selectedUser, page]);
|
}, [selectedUser, router.asPath]);
|
||||||
|
|
||||||
const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id);
|
|
||||||
const teacherFilter = (user: User) => user.type === "teacher" && groups.flatMap((g) => g.participants).includes(user.id);
|
|
||||||
|
|
||||||
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
||||||
|
|
||||||
@@ -210,64 +205,6 @@ export default function CorporateDashboard({user, linkedCorporate}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const StudentsList = () => {
|
|
||||||
const filter = (x: User) =>
|
|
||||||
x.type === "student" &&
|
|
||||||
(!!selectedUser
|
|
||||||
? groups
|
|
||||||
.filter((g) => g.admin === selectedUser.id)
|
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id) || false
|
|
||||||
: groups.flatMap((g) => g.participants).includes(x.id));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UserList
|
|
||||||
user={user}
|
|
||||||
filters={[filter]}
|
|
||||||
renderHeader={(total) => (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div
|
|
||||||
onClick={() => setPage("")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold">Students ({total})</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TeachersList = () => {
|
|
||||||
const filter = (x: User) =>
|
|
||||||
x.type === "teacher" &&
|
|
||||||
(!!selectedUser
|
|
||||||
? groups
|
|
||||||
.filter((g) => g.admin === selectedUser.id)
|
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id) || false
|
|
||||||
: groups.flatMap((g) => g.participants).includes(x.id));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UserList
|
|
||||||
user={user}
|
|
||||||
filters={[filter]}
|
|
||||||
renderHeader={(total) => (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div
|
|
||||||
onClick={() => setPage("")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold">Teachers ({total})</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const GroupsList = () => {
|
const GroupsList = () => {
|
||||||
const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id);
|
const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id);
|
||||||
|
|
||||||
@@ -275,7 +212,7 @@ export default function CorporateDashboard({user, linkedCorporate}: Props) {
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
onClick={() => router.push("/")}
|
||||||
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>
|
||||||
@@ -288,137 +225,30 @@ export default function CorporateDashboard({user, linkedCorporate}: Props) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AssignmentsPage = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AssignmentView
|
|
||||||
isOpen={!!selectedAssignment && !isCreatingAssignment}
|
|
||||||
onClose={() => {
|
|
||||||
setSelectedAssignment(undefined);
|
|
||||||
setIsCreatingAssignment(false);
|
|
||||||
reloadAssignments();
|
|
||||||
}}
|
|
||||||
assignment={selectedAssignment}
|
|
||||||
/>
|
|
||||||
<AssignmentCreator
|
|
||||||
assignment={selectedAssignment}
|
|
||||||
groups={assignmentsGroups}
|
|
||||||
users={assignmentsUsers}
|
|
||||||
isCreating={isCreatingAssignment}
|
|
||||||
cancelCreation={() => {
|
|
||||||
setIsCreatingAssignment(false);
|
|
||||||
setSelectedAssignment(undefined);
|
|
||||||
reloadAssignments();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="w-full flex justify-between items-center">
|
|
||||||
<div
|
|
||||||
onClick={() => setPage("")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
onClick={reloadAssignments}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<span>Reload</span>
|
|
||||||
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeAssignmentFilter).length})</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{assignments.filter(activeAssignmentFilter).map((a) => (
|
|
||||||
<AssignmentCard {...a} users={users} onClick={() => setSelectedAssignment(a)} key={a.id} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureAssignmentFilter).length})</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<div
|
|
||||||
onClick={() => setIsCreatingAssignment(true)}
|
|
||||||
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
|
|
||||||
<BsPlus className="text-6xl" />
|
|
||||||
<span className="text-lg">New Assignment</span>
|
|
||||||
</div>
|
|
||||||
{assignments.filter(futureAssignmentFilter).map((a) => (
|
|
||||||
<AssignmentCard
|
|
||||||
{...a}
|
|
||||||
users={users}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedAssignment(a);
|
|
||||||
setIsCreatingAssignment(true);
|
|
||||||
}}
|
|
||||||
key={a.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastAssignmentFilter).length})</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{assignments.filter(pastAssignmentFilter).map((a) => (
|
|
||||||
<AssignmentCard
|
|
||||||
{...a}
|
|
||||||
users={users}
|
|
||||||
onClick={() => setSelectedAssignment(a)}
|
|
||||||
key={a.id}
|
|
||||||
allowDownload
|
|
||||||
reload={reloadAssignments}
|
|
||||||
allowArchive
|
|
||||||
allowExcelDownload
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<h2 className="text-2xl font-semibold">Archived Assignments ({assignments.filter(archivedAssignmentFilter).length})</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{assignments.filter(archivedAssignmentFilter).map((a) => (
|
|
||||||
<AssignmentCard
|
|
||||||
{...a}
|
|
||||||
users={users}
|
|
||||||
onClick={() => setSelectedAssignment(a)}
|
|
||||||
key={a.id}
|
|
||||||
allowDownload
|
|
||||||
reload={reloadAssignments}
|
|
||||||
allowUnarchive
|
|
||||||
allowExcelDownload
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const StudentPerformancePage = () => {
|
const StudentPerformancePage = () => {
|
||||||
const students = users
|
const performanceStudents = students.map((u) => ({
|
||||||
.filter((x) => x.type === "student" && groups.flatMap((g) => g.participants).includes(x.id))
|
...u,
|
||||||
.map((u) => ({
|
group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A",
|
||||||
...u,
|
corporateName: getUserCompanyName(user, [], groups),
|
||||||
group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A",
|
}));
|
||||||
corporateName: getUserCompanyName(u, users, groups),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full flex justify-between items-center">
|
<div className="w-full flex justify-between items-center">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
onClick={() => router.push("/")}
|
||||||
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>
|
||||||
<div
|
<div
|
||||||
onClick={reload}
|
onClick={reloadStudents}
|
||||||
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">
|
||||||
<span>Reload</span>
|
<span>Reload</span>
|
||||||
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
|
<BsArrowRepeat className={clsx("text-xl", isStudentsLoading && "animate-spin")} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StudentPerformanceList items={students} stats={stats} users={users} />
|
<StudentPerformanceList items={performanceStudents} stats={stats} users={students} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -426,7 +256,7 @@ export default function CorporateDashboard({user, linkedCorporate}: Props) {
|
|||||||
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: students.find((u) => u.id === s.user)?.focus,
|
||||||
score: s.score,
|
score: s.score,
|
||||||
module: s.module,
|
module: s.module,
|
||||||
}))
|
}))
|
||||||
@@ -457,17 +287,19 @@ export default function CorporateDashboard({user, linkedCorporate}: Props) {
|
|||||||
)}
|
)}
|
||||||
<section className="grid grid-cols-5 -md:grid-cols-2 gap-4 text-center">
|
<section className="grid grid-cols-5 -md:grid-cols-2 gap-4 text-center">
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("students")}
|
onClick={() => router.push("/#students")}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
Icon={BsPersonFill}
|
Icon={BsPersonFill}
|
||||||
label="Students"
|
label="Students"
|
||||||
value={users.filter(studentFilter).length}
|
value={students.length}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("teachers")}
|
onClick={() => router.push("/#teachers")}
|
||||||
|
isLoading={isTeachersLoading}
|
||||||
Icon={BsPencilSquare}
|
Icon={BsPencilSquare}
|
||||||
label="Teachers"
|
label="Teachers"
|
||||||
value={users.filter(teacherFilter).length}
|
value={teachers.length}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
@@ -478,11 +310,12 @@ export default function CorporateDashboard({user, linkedCorporate}: Props) {
|
|||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsPaperclip}
|
Icon={BsPaperclip}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
label="Average Level"
|
label="Average Level"
|
||||||
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard onClick={() => setPage("groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
|
<IconCard onClick={() => router.push("/#groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsPersonCheck}
|
Icon={BsPersonCheck}
|
||||||
label="User Balance"
|
label="User Balance"
|
||||||
@@ -497,14 +330,15 @@ export default function CorporateDashboard({user, linkedCorporate}: Props) {
|
|||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsPersonFillGear}
|
Icon={BsPersonFillGear}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
label="Student Performance"
|
label="Student Performance"
|
||||||
value={users.filter(studentFilter).length}
|
value={students.length}
|
||||||
color="purple"
|
color="purple"
|
||||||
onClick={() => setPage("studentsPerformance")}
|
onClick={() => router.push("/#studentsPerformance")}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
disabled={isAssignmentsLoading}
|
disabled={isAssignmentsLoading}
|
||||||
onClick={() => setPage("assignments")}
|
onClick={() => router.push("/#assignments")}
|
||||||
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||||
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
||||||
<span className="flex flex-col gap-1 items-center text-xl">
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
@@ -520,8 +354,7 @@ export default function CorporateDashboard({user, linkedCorporate}: Props) {
|
|||||||
<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
|
{students
|
||||||
.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} />
|
||||||
@@ -531,8 +364,7 @@ export default function CorporateDashboard({user, linkedCorporate}: Props) {
|
|||||||
<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 teachers</span>
|
<span className="p-4">Latest teachers</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
|
{teachers
|
||||||
.filter(teacherFilter)
|
|
||||||
.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} />
|
||||||
@@ -542,8 +374,7 @@ export default function CorporateDashboard({user, linkedCorporate}: Props) {
|
|||||||
<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
|
{students
|
||||||
.filter(studentFilter)
|
|
||||||
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
@@ -553,8 +384,7 @@ export default function CorporateDashboard({user, linkedCorporate}: Props) {
|
|||||||
<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 exam count students</span>
|
<span className="p-4">Highest exam count 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
|
{students
|
||||||
.filter(studentFilter)
|
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
||||||
@@ -578,7 +408,8 @@ export default function CorporateDashboard({user, linkedCorporate}: Props) {
|
|||||||
loggedInUser={user}
|
loggedInUser={user}
|
||||||
onClose={(shouldReload) => {
|
onClose={(shouldReload) => {
|
||||||
setSelectedUser(undefined);
|
setSelectedUser(undefined);
|
||||||
if (shouldReload) reload();
|
if (shouldReload && selectedUser!.type === "student") reloadStudents();
|
||||||
|
if (shouldReload && selectedUser!.type === "teacher") reloadTeachers();
|
||||||
}}
|
}}
|
||||||
onViewStudents={
|
onViewStudents={
|
||||||
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
||||||
@@ -626,12 +457,54 @@ export default function CorporateDashboard({user, linkedCorporate}: Props) {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
</Modal>
|
</Modal>
|
||||||
{page === "students" && <StudentsList />}
|
{router.asPath === "/#students" && (
|
||||||
{page === "teachers" && <TeachersList />}
|
<UserList
|
||||||
{page === "groups" && <GroupsList />}
|
user={user}
|
||||||
{page === "assignments" && <AssignmentsPage />}
|
type="student"
|
||||||
{page === "studentsPerformance" && <StudentPerformancePage />}
|
renderHeader={(total) => (
|
||||||
{page === "" && <DefaultDashboard />}
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Students ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{router.asPath === "/#teachers" && (
|
||||||
|
<UserList
|
||||||
|
user={user}
|
||||||
|
type="teacher"
|
||||||
|
renderHeader={(total) => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Teachers ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{router.asPath === "/#groups" && <GroupsList />}
|
||||||
|
{router.asPath === "/#assignments" && (
|
||||||
|
<AssignmentsPage
|
||||||
|
assignments={assignments}
|
||||||
|
user={user}
|
||||||
|
groups={assignmentsGroups}
|
||||||
|
users={assignmentsUsers}
|
||||||
|
reloadAssignments={reloadAssignments}
|
||||||
|
isLoading={isAssignmentsLoading}
|
||||||
|
onBack={() => router.push("/")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{router.asPath === "/#studentsPerformance" && <StudentPerformancePage />}
|
||||||
|
{router.asPath === "/" && <DefaultDashboard />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React, {useMemo} from "react";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers, { userHashStudent, userHashTeacher, userHashCorporate } 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";
|
||||||
@@ -61,29 +61,17 @@ const Card = ({user}: {user: User}) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CorporateStudentsLevels = () => {
|
const CorporateStudentsLevels = () => {
|
||||||
const {users} = useUsers();
|
|
||||||
const {groups} = useGroups({});
|
|
||||||
|
|
||||||
const corporateUsers = users.filter((u) => u.type === "corporate") as User[];
|
|
||||||
const [corporateId, setCorporateId] = React.useState<string>("");
|
const [corporateId, setCorporateId] = React.useState<string>("");
|
||||||
const corporate = corporateUsers.find((u) => u.id === corporateId) || corporateUsers[0];
|
|
||||||
|
|
||||||
const groupsFromCorporate = corporate ? groups.filter((g) => g.admin === corporate.id) : [];
|
const {users: students} = useUsers(userHashStudent);
|
||||||
|
const {users: corporates} = useUsers(userHashCorporate);
|
||||||
|
|
||||||
const groupsParticipants = groupsFromCorporate
|
const corporate = useMemo(() => corporates.find((u) => u.id === corporateId) || corporates[0], [corporates, corporateId]);
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.reduce((accm: User[], p) => {
|
|
||||||
const user = users.find((u) => u.id === p) as User;
|
|
||||||
if (user) {
|
|
||||||
return [...accm, user];
|
|
||||||
}
|
|
||||||
return accm;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Select
|
<Select
|
||||||
options={corporateUsers.map((x: User) => ({
|
options={corporates.map((x: User) => ({
|
||||||
value: x.id,
|
value: x.id,
|
||||||
label: `${x.name} - ${x.email}`,
|
label: `${x.name} - ${x.email}`,
|
||||||
}))}
|
}))}
|
||||||
@@ -98,7 +86,7 @@ const CorporateStudentsLevels = () => {
|
|||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{groupsParticipants.map((u) => (
|
{students.map((u) => (
|
||||||
<Card user={u} key={u.id} />
|
<Card user={u} key={u.id} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,52 +1,43 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { IconType } from "react-icons";
|
import {IconType} from "react-icons";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
Icon: IconType;
|
Icon: IconType;
|
||||||
label: string;
|
label: string;
|
||||||
value?: string | number;
|
value?: string | number;
|
||||||
color: "purple" | "rose" | "red" | "green";
|
color: "purple" | "rose" | "red" | "green";
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
className?: string;
|
isLoading?: boolean;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function IconCard({
|
export default function IconCard({Icon, label, value, color, tooltip, onClick, className, isLoading, isSelected}: Props) {
|
||||||
Icon,
|
const colorClasses: {[key in typeof color]: string} = {
|
||||||
label,
|
purple: "mti-purple-light",
|
||||||
value,
|
red: "mti-red-light",
|
||||||
color,
|
rose: "mti-rose-light",
|
||||||
tooltip,
|
green: "mti-green-light",
|
||||||
onClick,
|
};
|
||||||
className,
|
|
||||||
isSelected,
|
|
||||||
}: Props) {
|
|
||||||
const colorClasses: { [key in typeof color]: string } = {
|
|
||||||
purple: "mti-purple-light",
|
|
||||||
red: "mti-red-light",
|
|
||||||
rose: "mti-rose-light",
|
|
||||||
green: "mti-green-light",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center text-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300",
|
"bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center text-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300",
|
||||||
tooltip && "tooltip tooltip-bottom",
|
tooltip && "tooltip tooltip-bottom",
|
||||||
isSelected && `border border-solid border-${colorClasses[color]}`,
|
isSelected && `border border-solid border-${colorClasses[color]}`,
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
data-tip={tooltip}
|
data-tip={tooltip}>
|
||||||
>
|
<Icon className={clsx("text-6xl", `text-${colorClasses[color]}`)} />
|
||||||
<Icon className={clsx("text-6xl", `text-${colorClasses[color]}`)} />
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
<span className="flex flex-col gap-1 items-center text-xl">
|
<span className="text-lg">{label}</span>
|
||||||
<span className="text-lg">{label}</span>
|
<span className={clsx("font-semibold", `text-${colorClasses[color]}`, isLoading && "animate-pulse")}>
|
||||||
<span className={clsx("font-semibold", `text-${colorClasses[color]}`)}>
|
{isLoading ? "..." : value}
|
||||||
{value}
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers, { userHashStudent, userHashTeacher, userHashCorporate } from "@/hooks/useUsers";
|
||||||
import {CorporateUser, Group, MasterCorporateUser, Stat, User} from "@/interfaces/user";
|
import {CorporateUser, Group, MasterCorporateUser, 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";
|
||||||
@@ -54,6 +54,7 @@ import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
|
|||||||
import MasterStatistical from "./MasterStatistical";
|
import MasterStatistical from "./MasterStatistical";
|
||||||
import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments";
|
import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments";
|
||||||
import useUserBalance from "@/hooks/useUserBalance";
|
import useUserBalance from "@/hooks/useUserBalance";
|
||||||
|
import AssignmentsPage from "./views/AssignmentsPage";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: MasterCorporateUser;
|
user: MasterCorporateUser;
|
||||||
@@ -198,7 +199,6 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
|
|||||||
];
|
];
|
||||||
|
|
||||||
const filterUsers = (data: StudentPerformanceItem[]) => {
|
const filterUsers = (data: StudentPerformanceItem[]) => {
|
||||||
console.log(data, selectedCorporate);
|
|
||||||
const filterByCorporate = (item: StudentPerformanceItem) => item.corporate?.id === selectedCorporate?.id;
|
const filterByCorporate = (item: StudentPerformanceItem) => item.corporate?.id === selectedCorporate?.id;
|
||||||
const filterByGroup = (item: StudentPerformanceItem) => item.group?.id === selectedGroup?.id;
|
const filterByGroup = (item: StudentPerformanceItem) => item.group?.id === selectedGroup?.id;
|
||||||
|
|
||||||
@@ -296,63 +296,56 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function MasterCorporateDashboard({user}: Props) {
|
export default function MasterCorporateDashboard({user}: Props) {
|
||||||
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 [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
|
||||||
const [corporateAssignments, setCorporateAssignments] = useState<(Assignment & {corporate?: CorporateUser})[]>([]);
|
const [corporateAssignments, setCorporateAssignments] = useState<(Assignment & {corporate?: CorporateUser})[]>([]);
|
||||||
|
|
||||||
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
||||||
const {users, reload} = useUsers();
|
|
||||||
|
const {users: students, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(userHashStudent);
|
||||||
|
const {users: teachers, reload: reloadTeachers, isLoading: isTeachersLoading} = useUsers(userHashTeacher);
|
||||||
|
const {users: corporates, reload: reloadCorporates, isLoading: isCorporatesLoading} = useUsers(userHashCorporate);
|
||||||
|
|
||||||
const {groups} = useGroups({admin: user.id, userType: user.type});
|
const {groups} = useGroups({admin: user.id, userType: user.type});
|
||||||
const {balance} = useUserBalance();
|
const {balance} = useUserBalance();
|
||||||
|
|
||||||
const masterCorporateUserGroups = useMemo(
|
const users = useMemo(() => uniqBy([...students, ...teachers, ...corporates, user], "id"), [corporates, students, teachers, user]);
|
||||||
() => [...new Set(groups.filter((u) => u.admin === user.id).flatMap((g) => g.participants))],
|
|
||||||
[groups, user.id],
|
|
||||||
);
|
|
||||||
|
|
||||||
const corporateUserGroups = useMemo(() => [...new Set(groups.flatMap((g) => g.participants))], [groups]);
|
|
||||||
|
|
||||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
|
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
|
||||||
|
|
||||||
const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]);
|
const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]);
|
||||||
const assignmentsUsers = useMemo(
|
const assignmentsUsers = useMemo(
|
||||||
() =>
|
() =>
|
||||||
users.filter(
|
[...students, ...teachers].filter((x) =>
|
||||||
(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) || false
|
||||||
.flatMap((g) => g.participants)
|
: groups.flatMap((g) => g.participants).includes(x.id),
|
||||||
.includes(x.id) || false
|
|
||||||
: groups.flatMap((g) => g.participants).includes(x.id)),
|
|
||||||
),
|
),
|
||||||
[groups, users, selectedUser],
|
[groups, selectedUser, teachers, students],
|
||||||
);
|
);
|
||||||
|
|
||||||
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowModal(!!selectedUser && page === "");
|
setShowModal(!!selectedUser && router.asPath === "/");
|
||||||
}, [selectedUser, page]);
|
}, [selectedUser, router.asPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCorporateAssignments(
|
setCorporateAssignments(
|
||||||
assignments.filter(activeAssignmentFilter).map((a) => ({
|
assignments.filter(activeAssignmentFilter).map((a) => {
|
||||||
...a,
|
const assigner = [...teachers, ...corporates].find((x) => x.id === a.assigner);
|
||||||
corporate: !!users.find((x) => x.id === a.assigner)
|
|
||||||
? getCorporateUser(users.find((x) => x.id === a.assigner)!, users, groups)
|
|
||||||
: undefined,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}, [assignments, groups, users]);
|
|
||||||
|
|
||||||
const studentFilter = (user: User) => user.type === "student" && corporateUserGroups.includes(user.id);
|
return {
|
||||||
const teacherFilter = (user: User) => user.type === "teacher" && corporateUserGroups.includes(user.id);
|
...a,
|
||||||
|
corporate: assigner ? getCorporateUser(assigner, [...teachers, ...corporates], groups) : undefined,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [assignments, groups, teachers, corporates]);
|
||||||
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
||||||
|
|
||||||
const UserDisplay = (displayUser: User) => (
|
const UserDisplay = (displayUser: User) => (
|
||||||
@@ -367,81 +360,14 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const StudentsList = () => {
|
const corporateUserFilter = (x: User) => x.type === "corporate";
|
||||||
const filter = (x: User) =>
|
|
||||||
x.type === "student" && (!!selectedUser ? corporateUserGroups.includes(x.id) || false : corporateUserGroups.includes(x.id));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UserList
|
|
||||||
user={user}
|
|
||||||
filters={[filter]}
|
|
||||||
renderHeader={(total) => (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div
|
|
||||||
onClick={() => setPage("")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold">Students ({total})</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TeachersList = () => {
|
|
||||||
const filter = (x: User) =>
|
|
||||||
x.type === "teacher" && (!!selectedUser ? corporateUserGroups.includes(x.id) || false : corporateUserGroups.includes(x.id));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UserList
|
|
||||||
user={user}
|
|
||||||
filters={[filter]}
|
|
||||||
renderHeader={(total) => (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div
|
|
||||||
onClick={() => setPage("")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold">Teachers ({total})</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const corporateUserFilter = (x: User) =>
|
|
||||||
x.type === "corporate" && (!!selectedUser ? masterCorporateUserGroups.includes(x.id) || false : masterCorporateUserGroups.includes(x.id));
|
|
||||||
|
|
||||||
const CorporateList = () => {
|
|
||||||
return (
|
|
||||||
<UserList
|
|
||||||
user={user}
|
|
||||||
filters={[corporateUserFilter]}
|
|
||||||
renderHeader={(total) => (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div
|
|
||||||
onClick={() => setPage("")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold">Corporates ({total})</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const GroupsList = () => {
|
const GroupsList = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
onClick={() => router.push("/")}
|
||||||
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>
|
||||||
@@ -455,19 +381,11 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const StudentPerformancePage = () => {
|
const StudentPerformancePage = () => {
|
||||||
const students = users
|
|
||||||
.filter((x) => x.type === "student" && groups.flatMap((g) => g.participants).includes(x.id))
|
|
||||||
.map((u) => ({
|
|
||||||
...u,
|
|
||||||
group: groups.find((x) => x.participants.includes(u.id)),
|
|
||||||
corporate: getCorporateUser(u, users, groups),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full flex justify-between items-center">
|
<div className="w-full flex justify-between items-center">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
onClick={() => router.push("/")}
|
||||||
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>
|
||||||
@@ -479,157 +397,24 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
|
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StudentPerformanceList items={students} stats={stats} users={users} groups={groups} />
|
<StudentPerformanceList items={students} stats={stats} users={corporates} groups={groups} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AssignmentsPage = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AssignmentView
|
|
||||||
isOpen={!!selectedAssignment && !isCreatingAssignment}
|
|
||||||
onClose={() => {
|
|
||||||
setSelectedAssignment(undefined);
|
|
||||||
setIsCreatingAssignment(false);
|
|
||||||
reloadAssignments();
|
|
||||||
}}
|
|
||||||
assignment={selectedAssignment}
|
|
||||||
/>
|
|
||||||
<AssignmentCreator
|
|
||||||
assignment={selectedAssignment}
|
|
||||||
groups={assignmentsGroups}
|
|
||||||
users={assignmentsUsers}
|
|
||||||
isCreating={isCreatingAssignment}
|
|
||||||
cancelCreation={() => {
|
|
||||||
setIsCreatingAssignment(false);
|
|
||||||
setSelectedAssignment(undefined);
|
|
||||||
reloadAssignments();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="w-full flex justify-between items-center">
|
|
||||||
<div
|
|
||||||
onClick={() => setPage("")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
onClick={reloadAssignments}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<span>Reload</span>
|
|
||||||
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span className="text-lg font-bold">Active Assignments Status</span>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span>
|
|
||||||
<b>Total:</b> {assignments.filter(activeAssignmentFilter).reduce((acc, curr) => acc + curr.results.length, 0)}/
|
|
||||||
{assignments.filter(activeAssignmentFilter).reduce((acc, curr) => curr.exams.length + acc, 0)}
|
|
||||||
</span>
|
|
||||||
{Object.keys(groupBy(corporateAssignments, (x) => x.corporate?.id)).map((x) => (
|
|
||||||
<div key={x}>
|
|
||||||
<span className="font-semibold">{getUserCompanyName(users.find((u) => u.id === x)!, users, groups)}: </span>
|
|
||||||
<span>
|
|
||||||
{groupBy(corporateAssignments, (x) => x.corporate?.id)[x].reduce((acc, curr) => curr.results.length + acc, 0)}/
|
|
||||||
{groupBy(corporateAssignments, (x) => x.corporate?.id)[x].reduce((acc, curr) => curr.exams.length + acc, 0)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeAssignmentFilter).length})</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{assignments.filter(activeAssignmentFilter).map((a) => (
|
|
||||||
<AssignmentCard {...a} users={users} onClick={() => setSelectedAssignment(a)} key={a.id} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureAssignmentFilter).length})</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<div
|
|
||||||
onClick={() => setIsCreatingAssignment(true)}
|
|
||||||
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
|
|
||||||
<BsPlus className="text-6xl" />
|
|
||||||
<span className="text-lg">New Assignment</span>
|
|
||||||
</div>
|
|
||||||
{assignments.filter(futureAssignmentFilter).map((a) => (
|
|
||||||
<AssignmentCard
|
|
||||||
{...a}
|
|
||||||
users={users}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedAssignment(a);
|
|
||||||
setIsCreatingAssignment(true);
|
|
||||||
}}
|
|
||||||
key={a.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastAssignmentFilter).length})</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{assignments.filter(pastAssignmentFilter).map((a) => (
|
|
||||||
<AssignmentCard
|
|
||||||
{...a}
|
|
||||||
users={users}
|
|
||||||
onClick={() => setSelectedAssignment(a)}
|
|
||||||
key={a.id}
|
|
||||||
allowDownload
|
|
||||||
reload={reloadAssignments}
|
|
||||||
allowArchive
|
|
||||||
allowExcelDownload
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<h2 className="text-2xl font-semibold">Archived Assignments ({assignments.filter(archivedAssignmentFilter).length})</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{assignments.filter(archivedAssignmentFilter).map((a) => (
|
|
||||||
<AssignmentCard
|
|
||||||
{...a}
|
|
||||||
users={users}
|
|
||||||
onClick={() => setSelectedAssignment(a)}
|
|
||||||
key={a.id}
|
|
||||||
allowDownload
|
|
||||||
reload={reloadAssignments}
|
|
||||||
allowUnarchive
|
|
||||||
allowExcelDownload
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const masterCorporateUsers = useMemo(
|
|
||||||
() =>
|
|
||||||
masterCorporateUserGroups.reduce((accm: CorporateUser[], id) => {
|
|
||||||
const user = users.find((u) => u.id === id) as CorporateUser;
|
|
||||||
if (user) return [...accm, user];
|
|
||||||
return accm;
|
|
||||||
}, []),
|
|
||||||
[masterCorporateUserGroups, users],
|
|
||||||
);
|
|
||||||
|
|
||||||
const MasterStatisticalPage = () => {
|
const MasterStatisticalPage = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
onClick={() => router.push("/")}
|
||||||
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">Master Statistical</h2>
|
<h2 className="text-2xl font-semibold">Master Statistical</h2>
|
||||||
</div>
|
</div>
|
||||||
<MasterStatistical users={users} corporateUsers={masterCorporateUsers} />
|
<MasterStatistical users={users} corporateUsers={corporates} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -638,17 +423,19 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
<>
|
<>
|
||||||
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center">
|
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center">
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("students")}
|
onClick={() => router.push("/#students")}
|
||||||
Icon={BsPersonFill}
|
Icon={BsPersonFill}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
label="Students"
|
label="Students"
|
||||||
value={users.filter(studentFilter).length}
|
value={students.length}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("teachers")}
|
onClick={() => router.push("/#teachers")}
|
||||||
Icon={BsPencilSquare}
|
Icon={BsPencilSquare}
|
||||||
|
isLoading={isTeachersLoading}
|
||||||
label="Teachers"
|
label="Teachers"
|
||||||
value={users.filter(teacherFilter).length}
|
value={teachers.length}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
@@ -661,12 +448,12 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
Icon={BsPaperclip}
|
Icon={BsPaperclip}
|
||||||
label="Average Level"
|
label="Average Level"
|
||||||
value={averageLevelCalculator(
|
value={averageLevelCalculator(
|
||||||
users,
|
students,
|
||||||
stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)),
|
stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)),
|
||||||
).toFixed(1)}
|
).toFixed(1)}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard onClick={() => setPage("groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
|
<IconCard onClick={() => router.push("/#groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsPersonCheck}
|
Icon={BsPersonCheck}
|
||||||
label="User Balance"
|
label="User Balance"
|
||||||
@@ -682,27 +469,29 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsBank}
|
Icon={BsBank}
|
||||||
label="Corporate"
|
label="Corporate"
|
||||||
value={masterCorporateUserGroups.length}
|
value={corporates.length}
|
||||||
|
isLoading={isCorporatesLoading}
|
||||||
color="purple"
|
color="purple"
|
||||||
onClick={() => setPage("corporate")}
|
onClick={() => router.push("/#corporate")}
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsPersonFillGear}
|
Icon={BsPersonFillGear}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
label="Student Performance"
|
label="Student Performance"
|
||||||
value={users.filter(studentFilter).length}
|
value={students.length}
|
||||||
color="purple"
|
color="purple"
|
||||||
onClick={() => setPage("studentsPerformance")}
|
onClick={() => router.push("/#studentsPerformance")}
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsDatabase}
|
Icon={BsDatabase}
|
||||||
label="Master Statistical"
|
label="Master Statistical"
|
||||||
// value={masterCorporateUserGroups.length}
|
// value={masterCorporateUserGroups.length}
|
||||||
color="purple"
|
color="purple"
|
||||||
onClick={() => setPage("statistical")}
|
onClick={() => router.push("/#statistical")}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
disabled={isAssignmentsLoading}
|
disabled={isAssignmentsLoading}
|
||||||
onClick={() => setPage("assignments")}
|
onClick={() => router.push("/#assignments")}
|
||||||
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||||
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
||||||
<span className="flex flex-col gap-1 items-center text-xl">
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
@@ -718,8 +507,7 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
<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
|
{students
|
||||||
.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} />
|
||||||
@@ -729,8 +517,7 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
<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 teachers</span>
|
<span className="p-4">Latest teachers</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
|
{teachers
|
||||||
.filter(teacherFilter)
|
|
||||||
.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} />
|
||||||
@@ -740,8 +527,7 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
<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
|
{students
|
||||||
.filter(studentFilter)
|
|
||||||
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
@@ -751,8 +537,7 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
<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 exam count students</span>
|
<span className="p-4">Highest exam count 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
|
{students
|
||||||
.filter(studentFilter)
|
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
||||||
@@ -781,7 +566,9 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
loggedInUser={user}
|
loggedInUser={user}
|
||||||
onClose={(shouldReload) => {
|
onClose={(shouldReload) => {
|
||||||
setSelectedUser(undefined);
|
setSelectedUser(undefined);
|
||||||
if (shouldReload) reload();
|
if (shouldReload && selectedUser!.type === "student") reloadStudents();
|
||||||
|
if (shouldReload && selectedUser!.type === "teacher") reloadTeachers();
|
||||||
|
if (shouldReload && selectedUser!.type === "corporate") reloadCorporates();
|
||||||
}}
|
}}
|
||||||
onViewStudents={
|
onViewStudents={
|
||||||
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
||||||
@@ -829,14 +616,73 @@ export default function MasterCorporateDashboard({user}: Props) {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
</Modal>
|
</Modal>
|
||||||
{page === "students" && <StudentsList />}
|
{router.asPath === "/#students" && (
|
||||||
{page === "teachers" && <TeachersList />}
|
<UserList
|
||||||
{page === "groups" && <GroupsList />}
|
user={user}
|
||||||
{page === "corporate" && <CorporateList />}
|
type="student"
|
||||||
{page === "assignments" && <AssignmentsPage />}
|
renderHeader={(total) => (
|
||||||
{page === "studentsPerformance" && <StudentPerformancePage />}
|
<div className="flex flex-col gap-4">
|
||||||
{page === "statistical" && <MasterStatisticalPage />}
|
<div
|
||||||
{page === "" && <DefaultDashboard />}
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Students ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{router.asPath === "/#teachers" && (
|
||||||
|
<UserList
|
||||||
|
user={user}
|
||||||
|
type="teacher"
|
||||||
|
renderHeader={(total) => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Teachers ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{router.asPath === "/#groups" && <GroupsList />}
|
||||||
|
{router.asPath === "/#corporate" && (
|
||||||
|
<UserList
|
||||||
|
user={user}
|
||||||
|
type="corporate"
|
||||||
|
renderHeader={(total) => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Corporate ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{router.asPath === "/#assignments" && (
|
||||||
|
<AssignmentsPage
|
||||||
|
assignments={assignments}
|
||||||
|
corporateAssignments={corporateAssignments}
|
||||||
|
groups={assignmentsGroups}
|
||||||
|
user={user}
|
||||||
|
users={assignmentsUsers}
|
||||||
|
reloadAssignments={reloadAssignments}
|
||||||
|
isLoading={isAssignmentsLoading}
|
||||||
|
onBack={() => router.push("/")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{router.asPath === "/#studentsPerformance" && <StudentPerformancePage />}
|
||||||
|
{router.asPath === "/#statistical" && <MasterStatisticalPage />}
|
||||||
|
{router.asPath === "/" && <DefaultDashboard />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { CorporateUser, User } from "@/interfaces/user";
|
import { CorporateUser, User } from "@/interfaces/user";
|
||||||
import { BsBank, BsPersonFill } from "react-icons/bs";
|
import { BsFileExcel, BsBank, BsPersonFill } from "react-icons/bs";
|
||||||
import IconCard from "./IconCard";
|
import IconCard from "./IconCard";
|
||||||
|
|
||||||
import useAssignmentsCorporates from "@/hooks/useAssignmentCorporates";
|
import useAssignmentsCorporates from "@/hooks/useAssignmentCorporates";
|
||||||
import ReactDatePicker from "react-datepicker";
|
import ReactDatePicker from "react-datepicker";
|
||||||
|
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { Assignment, AssignmentWithCorporateId } from "@/interfaces/results";
|
import { AssignmentWithCorporateId } from "@/interfaces/results";
|
||||||
import {
|
import {
|
||||||
CellContext,
|
|
||||||
createColumnHelper,
|
|
||||||
flexRender,
|
flexRender,
|
||||||
|
createColumnHelper,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
HeaderGroup,
|
|
||||||
Table,
|
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
import { useListSearch } from "@/hooks/useListSearch";
|
||||||
|
import axios from "axios";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import Button from "@/components/Low/Button";
|
||||||
interface Props {
|
interface Props {
|
||||||
corporateUsers: User[];
|
corporateUsers: User[];
|
||||||
users: User[];
|
users: User[];
|
||||||
@@ -24,6 +26,7 @@ interface Props {
|
|||||||
|
|
||||||
interface TableData {
|
interface TableData {
|
||||||
user: string;
|
user: string;
|
||||||
|
email: string;
|
||||||
correct: number;
|
correct: number;
|
||||||
corporate: string;
|
corporate: string;
|
||||||
submitted: boolean;
|
submitted: boolean;
|
||||||
@@ -37,6 +40,8 @@ interface UserCount {
|
|||||||
maxUserCount: number;
|
maxUserCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchFilters = [["email"], ["user"], ["userId"]];
|
||||||
|
|
||||||
const MasterStatistical = (props: Props) => {
|
const MasterStatistical = (props: Props) => {
|
||||||
const { users, corporateUsers } = props;
|
const { users, corporateUsers } = props;
|
||||||
|
|
||||||
@@ -66,16 +71,20 @@ const MasterStatistical = (props: Props) => {
|
|||||||
endDate,
|
endDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [downloading, setDownloading] = React.useState<boolean>(false);
|
||||||
|
|
||||||
const tableResults = React.useMemo(
|
const tableResults = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
assignments.reduce((accmA: TableData[], a: AssignmentWithCorporateId) => {
|
assignments.reduce((accmA: TableData[], a: AssignmentWithCorporateId) => {
|
||||||
const userResults = a.assignees.map((assignee) => {
|
const userResults = a.assignees.map((assignee) => {
|
||||||
const userStats =
|
const userStats =
|
||||||
a.results.find((r) => r.user === assignee)?.stats || [];
|
a.results.find((r) => r.user === assignee)?.stats || [];
|
||||||
const userName = users.find((u) => u.id === assignee)?.name || "";
|
const userData = users.find((u) => u.id === assignee);
|
||||||
const corporate = users.find((u) => u.id === a.assigner)?.name || "";
|
const corporate = users.find((u) => u.id === a.assigner)?.name || "";
|
||||||
const commonData = {
|
const commonData = {
|
||||||
user: userName,
|
user: userData?.name || "",
|
||||||
|
email: userData?.email || "",
|
||||||
|
userId: assignee,
|
||||||
corporateId: a.corporateId,
|
corporateId: a.corporateId,
|
||||||
corporate,
|
corporate,
|
||||||
assignment: a.name,
|
assignment: a.name,
|
||||||
@@ -146,6 +155,13 @@ const MasterStatistical = (props: Props) => {
|
|||||||
return <span>{info.getValue()}</span>;
|
return <span>{info.getValue()}</span>;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
columnHelper.accessor("email", {
|
||||||
|
header: "Email",
|
||||||
|
id: "email",
|
||||||
|
cell: (info) => {
|
||||||
|
return <span>{info.getValue()}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
columnHelper.accessor("corporate", {
|
columnHelper.accessor("corporate", {
|
||||||
header: "Corporate",
|
header: "Corporate",
|
||||||
id: "corporate",
|
id: "corporate",
|
||||||
@@ -192,8 +208,14 @@ const MasterStatistical = (props: Props) => {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const {
|
||||||
|
rows: filteredRows,
|
||||||
|
renderSearch,
|
||||||
|
text: searchText,
|
||||||
|
} = useListSearch(searchFilters, tableResults);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: tableResults,
|
data: filteredRows,
|
||||||
columns: defaultColumns,
|
columns: defaultColumns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
});
|
});
|
||||||
@@ -220,6 +242,32 @@ const MasterStatistical = (props: Props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const triggerDownload = async () => {
|
||||||
|
try {
|
||||||
|
setDownloading(true);
|
||||||
|
const res = await axios.post("/api/assignments/statistical/excel", {
|
||||||
|
ids: selectedCorporates,
|
||||||
|
...(startDate ? { startDate: startDate.toISOString() } : {}),
|
||||||
|
...(endDate ? { endDate: endDate.toISOString() } : {}),
|
||||||
|
searchText,
|
||||||
|
});
|
||||||
|
toast.success("Report ready!");
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = res.data;
|
||||||
|
// download should have worked but there are some CORS issues
|
||||||
|
// https://firebase.google.com/docs/storage/web/download-files#cors_configuration
|
||||||
|
// link.download="report.pdf";
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noreferrer";
|
||||||
|
link.click();
|
||||||
|
setDownloading(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Failed to display the report!");
|
||||||
|
console.error(err);
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const consolidateResults = getStudentsConsolidateScore();
|
const consolidateResults = getStudentsConsolidateScore();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -261,28 +309,43 @@ const MasterStatistical = (props: Props) => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Date</label>
|
<div className="flex flex-col gap-3">
|
||||||
<ReactDatePicker
|
<label className="font-normal text-base text-mti-gray-dim">
|
||||||
dateFormat="dd/MM/yyyy"
|
Date
|
||||||
className="px-4 py-6 w-52 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"
|
</label>
|
||||||
selected={startDate}
|
<ReactDatePicker
|
||||||
startDate={startDate}
|
dateFormat="dd/MM/yyyy"
|
||||||
endDate={endDate}
|
className="px-4 py-6 w-52 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"
|
||||||
selectsRange
|
selected={startDate}
|
||||||
showMonthDropdown
|
startDate={startDate}
|
||||||
onChange={([initialDate, finalDate]: [Date, Date]) => {
|
endDate={endDate}
|
||||||
setStartDate(initialDate ?? moment("01/01/2023").toDate());
|
selectsRange
|
||||||
if (finalDate) {
|
showMonthDropdown
|
||||||
// basicly selecting a final day works as if I'm selecting the first
|
onChange={([initialDate, finalDate]: [Date, Date]) => {
|
||||||
// minute of that day. this way it covers the whole day
|
setStartDate(initialDate ?? moment("01/01/2023").toDate());
|
||||||
setEndDate(moment(finalDate).endOf("day").toDate());
|
if (finalDate) {
|
||||||
return;
|
// basicly selecting a final day works as if I'm selecting the first
|
||||||
}
|
// minute of that day. this way it covers the whole day
|
||||||
setEndDate(null);
|
setEndDate(moment(finalDate).endOf("day").toDate());
|
||||||
}}
|
return;
|
||||||
/>
|
}
|
||||||
|
setEndDate(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{renderSearch()}
|
||||||
|
<div className="flex flex-col gap-3 justify-end">
|
||||||
|
<Button
|
||||||
|
className="max-w-[200px] h-[70px]"
|
||||||
|
variant="outline"
|
||||||
|
onClick={triggerDownload}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<table className="rounded-xl h-full bg-mti-purple-ultralight/40 w-full">
|
<table className="rounded-xl h-full bg-mti-purple-ultralight/40 w-full">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import useAssignments from "@/hooks/useAssignments";
|
|||||||
import useGradingSystem from "@/hooks/useGrading";
|
import useGradingSystem from "@/hooks/useGrading";
|
||||||
import useInvites from "@/hooks/useInvites";
|
import useInvites from "@/hooks/useInvites";
|
||||||
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers, { userHashStudent, userHashTeacher, userHashCorporate} from "@/hooks/useUsers";
|
||||||
import {Invite} from "@/interfaces/invite";
|
import {Invite} from "@/interfaces/invite";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
import {CorporateUser, MasterCorporateUser, Stat, User} from "@/interfaces/user";
|
import {CorporateUser, MasterCorporateUser, Stat, User} from "@/interfaces/user";
|
||||||
@@ -24,9 +24,12 @@ import {capitalize} from "lodash";
|
|||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useMemo, useState} from "react";
|
||||||
import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs";
|
import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
|
import {activeAssignmentFilter} from "@/utils/assignments";
|
||||||
|
import ModuleBadge from "@/components/ModuleBadge";
|
||||||
|
import useSessions from "@/hooks/useSessions";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -34,12 +37,16 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function StudentDashboard({user, linkedCorporate}: Props) {
|
export default function StudentDashboard({user, linkedCorporate}: Props) {
|
||||||
const {users} = useUsers();
|
|
||||||
const {gradingSystem} = useGradingSystem();
|
const {gradingSystem} = useGradingSystem();
|
||||||
|
const {sessions} = useSessions(user.id);
|
||||||
const {data: stats} = useFilterRecordsByUser<Stat[]>(user.id, !user?.id);
|
const {data: stats} = useFilterRecordsByUser<Stat[]>(user.id, !user?.id);
|
||||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
|
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
|
||||||
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user.id});
|
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user.id});
|
||||||
|
|
||||||
|
const {users: teachers} = useUsers(userHashTeacher);
|
||||||
|
const {users: corporates} = useUsers(userHashCorporate);
|
||||||
|
|
||||||
|
const users = useMemo(() => [...teachers, ...corporates], [teachers, corporates]);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
const setExams = useExamStore((state) => state.setExams);
|
||||||
@@ -69,6 +76,8 @@ export default function StudentDashboard({user, linkedCorporate}: Props) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const studentAssignments = assignments.filter(activeAssignmentFilter);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{linkedCorporate && (
|
{linkedCorporate && (
|
||||||
@@ -119,50 +128,32 @@ export default function StudentDashboard({user, linkedCorporate}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
|
||||||
{assignments.filter((a) => moment(a.endDate).isSameOrAfter(moment())).length === 0 &&
|
{studentAssignments.length === 0 && "Assignments will appear here. It seems that for now there are no assignments for you."}
|
||||||
"Assignments will appear here. It seems that for now there are no assignments for you."}
|
{studentAssignments
|
||||||
{assignments
|
|
||||||
.filter((a) => moment(a.endDate).isSameOrAfter(moment()))
|
|
||||||
.sort((a, b) => moment(a.startDate).diff(b.startDate))
|
.sort((a, b) => moment(a.startDate).diff(b.startDate))
|
||||||
.map((assignment) => (
|
.map((assignment) => (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"border-mti-gray-anti-flash flex min-w-[300px] flex-col gap-6 rounded-xl border p-4",
|
"border-mti-gray-anti-flash flex min-w-[350px] flex-col gap-6 rounded-xl border p-4",
|
||||||
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light",
|
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light",
|
||||||
)}
|
)}
|
||||||
key={assignment.id}>
|
key={assignment.id}>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3>
|
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3>
|
||||||
<span className="flex justify-between gap-1">
|
<span className="flex justify-between gap-1 text-lg">
|
||||||
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
|
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
<div className="-md:mt-2 grid w-fit min-w-[104px] grid-cols-2 place-items-center justify-center gap-2">
|
<div className="-md:mt-2 grid w-fit min-w-[140px] grid-cols-2 grid-rows-2 place-items-center justify-between gap-4">
|
||||||
{assignment.exams
|
{assignment.exams
|
||||||
.filter((e) => e.assignee === user.id)
|
.filter((e) => e.assignee === user.id)
|
||||||
.map((e) => e.module)
|
.map((e) => e.module)
|
||||||
.sort(sortByModuleName)
|
.sort(sortByModuleName)
|
||||||
.map((module) => (
|
.map((module) => (
|
||||||
<div
|
<ModuleBadge className="scale-110 w-full" key={module} module={module} />
|
||||||
key={module}
|
|
||||||
data-tip={capitalize(module)}
|
|
||||||
className={clsx(
|
|
||||||
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
|
||||||
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="h-4 w-4" />}
|
|
||||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
|
||||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
|
||||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
|
||||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!assignment.results.map((r) => r.user).includes(user.id) && (
|
{!assignment.results.map((r) => r.user).includes(user.id) && (
|
||||||
@@ -170,17 +161,24 @@ export default function StudentDashboard({user, linkedCorporate}: Props) {
|
|||||||
<div
|
<div
|
||||||
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
|
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
|
||||||
data-tip="Your screen size is too small to perform an assignment">
|
data-tip="Your screen size is too small to perform an assignment">
|
||||||
<Button disabled={!assignment.start} className="h-full w-full !rounded-xl" variant="outline">
|
<Button className="h-full w-full !rounded-xl" variant="outline">
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-tip="You have already started this assignment!"
|
||||||
|
className={clsx(
|
||||||
|
"-md:hidden h-full w-full max-w-[50%] cursor-pointer",
|
||||||
|
sessions.filter((x) => x.assignment?.id === assignment.id).length > 0 && "tooltip",
|
||||||
|
)}>
|
||||||
|
<Button
|
||||||
|
className={clsx("w-full h-full !rounded-xl")}
|
||||||
|
onClick={() => startAssignment(assignment)}
|
||||||
|
variant="outline"
|
||||||
|
disabled={sessions.filter((x) => x.assignment?.id === assignment.id).length > 0}>
|
||||||
Start
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
disabled={!assignment.start}
|
|
||||||
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
|
|
||||||
onClick={() => startAssignment(assignment)}
|
|
||||||
variant="outline">
|
|
||||||
Start
|
|
||||||
</Button>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{assignment.results.map((r) => r.user).includes(user.id) && (
|
{assignment.results.map((r) => r.user).includes(user.id) && (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers, { userHashStudent, userHashTeacher, userHashCorporate } from "@/hooks/useUsers";
|
||||||
import {CorporateUser, Group, MasterCorporateUser, Stat, User} from "@/interfaces/user";
|
import {CorporateUser, Group, MasterCorporateUser, 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";
|
||||||
@@ -49,6 +49,9 @@ import {getUserCorporate} from "@/utils/groups";
|
|||||||
import {checkAccess} from "@/utils/permissions";
|
import {checkAccess} from "@/utils/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments";
|
import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments";
|
||||||
|
import AssignmentsPage from "./views/AssignmentsPage";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -56,40 +59,37 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TeacherDashboard({user, linkedCorporate}: Props) {
|
export default function TeacherDashboard({user, linkedCorporate}: Props) {
|
||||||
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 [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
|
||||||
|
|
||||||
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
||||||
const {users, reload} = useUsers();
|
|
||||||
const {groups} = useGroups({adminAdmins: user.id});
|
const {groups} = useGroups({adminAdmins: user.id});
|
||||||
const {permissions} = usePermissions(user.id);
|
const {permissions} = usePermissions(user.id);
|
||||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id});
|
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id});
|
||||||
|
|
||||||
|
const {users: students, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(userHashStudent);
|
||||||
|
|
||||||
|
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]);
|
const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]);
|
||||||
|
|
||||||
const assignmentsUsers = useMemo(
|
const assignmentsUsers = useMemo(
|
||||||
() =>
|
() =>
|
||||||
users.filter(
|
students.filter((x) =>
|
||||||
(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)
|
|
||||||
: groups.flatMap((g) => g.participants).includes(x.id)),
|
|
||||||
),
|
),
|
||||||
[groups, users, selectedUser],
|
[groups, students, selectedUser],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowModal(!!selectedUser && page === "");
|
setShowModal(!!selectedUser && router.asPath === "/#");
|
||||||
}, [selectedUser, page]);
|
}, [selectedUser, router.asPath]);
|
||||||
|
|
||||||
const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id);
|
|
||||||
|
|
||||||
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
||||||
|
|
||||||
@@ -105,35 +105,6 @@ export default function TeacherDashboard({user, linkedCorporate}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const StudentsList = () => {
|
|
||||||
const filter = (x: User) =>
|
|
||||||
x.type === "student" &&
|
|
||||||
(!!selectedUser
|
|
||||||
? groups
|
|
||||||
.filter((g) => g.admin === selectedUser.id)
|
|
||||||
.flatMap((g) => g.participants)
|
|
||||||
.includes(x.id) || false
|
|
||||||
: groups.flatMap((g) => g.participants).includes(x.id));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UserList
|
|
||||||
user={user}
|
|
||||||
filters={[filter]}
|
|
||||||
renderHeader={(total) => (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div
|
|
||||||
onClick={() => setPage("")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold">Students ({total})</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const GroupsList = () => {
|
const GroupsList = () => {
|
||||||
const filter = (x: Group) => x.admin === user.id;
|
const filter = (x: Group) => x.admin === user.id;
|
||||||
|
|
||||||
@@ -141,7 +112,7 @@ export default function TeacherDashboard({user, linkedCorporate}: Props) {
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("")}
|
onClick={() => router.push("/")}
|
||||||
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>
|
||||||
@@ -157,7 +128,7 @@ export default function TeacherDashboard({user, linkedCorporate}: Props) {
|
|||||||
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: students.find((u) => u.id === s.user)?.focus,
|
||||||
score: s.score,
|
score: s.score,
|
||||||
module: s.module,
|
module: s.module,
|
||||||
}))
|
}))
|
||||||
@@ -179,111 +150,6 @@ export default function TeacherDashboard({user, linkedCorporate}: Props) {
|
|||||||
return calculateAverageLevel(levels);
|
return calculateAverageLevel(levels);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AssignmentsPage = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AssignmentView
|
|
||||||
isOpen={!!selectedAssignment && !isCreatingAssignment}
|
|
||||||
onClose={() => {
|
|
||||||
setSelectedAssignment(undefined);
|
|
||||||
setIsCreatingAssignment(false);
|
|
||||||
reloadAssignments();
|
|
||||||
}}
|
|
||||||
assignment={selectedAssignment}
|
|
||||||
/>
|
|
||||||
<AssignmentCreator
|
|
||||||
assignment={selectedAssignment}
|
|
||||||
groups={assignmentsGroups}
|
|
||||||
users={assignmentsUsers}
|
|
||||||
isCreating={isCreatingAssignment}
|
|
||||||
cancelCreation={() => {
|
|
||||||
setIsCreatingAssignment(false);
|
|
||||||
setSelectedAssignment(undefined);
|
|
||||||
reloadAssignments();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="w-full flex justify-between items-center">
|
|
||||||
<div
|
|
||||||
onClick={() => setPage("")}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
onClick={reloadAssignments}
|
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
|
||||||
<span>Reload</span>
|
|
||||||
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeAssignmentFilter).length})</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{assignments.filter(activeAssignmentFilter).map((a) => (
|
|
||||||
<AssignmentCard {...a} users={users} onClick={() => setSelectedAssignment(a)} key={a.id} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureAssignmentFilter).length})</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<div
|
|
||||||
onClick={() => setIsCreatingAssignment(true)}
|
|
||||||
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
|
|
||||||
<BsPlus className="text-6xl" />
|
|
||||||
<span className="text-lg">New Assignment</span>
|
|
||||||
</div>
|
|
||||||
{assignments.filter(futureAssignmentFilter).map((a) => (
|
|
||||||
<AssignmentCard
|
|
||||||
{...a}
|
|
||||||
users={users}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedAssignment(a);
|
|
||||||
setIsCreatingAssignment(true);
|
|
||||||
}}
|
|
||||||
key={a.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastAssignmentFilter).length})</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{assignments.filter(pastAssignmentFilter).map((a) => (
|
|
||||||
<AssignmentCard
|
|
||||||
{...a}
|
|
||||||
users={users}
|
|
||||||
onClick={() => setSelectedAssignment(a)}
|
|
||||||
key={a.id}
|
|
||||||
allowDownload
|
|
||||||
reload={reloadAssignments}
|
|
||||||
allowArchive
|
|
||||||
allowExcelDownload
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<h2 className="text-2xl font-semibold">Archived Assignments ({assignments.filter(archivedAssignmentFilter).length})</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{assignments.filter(archivedAssignmentFilter).map((a) => (
|
|
||||||
<AssignmentCard
|
|
||||||
{...a}
|
|
||||||
users={users}
|
|
||||||
onClick={() => setSelectedAssignment(a)}
|
|
||||||
key={a.id}
|
|
||||||
allowDownload
|
|
||||||
reload={reloadAssignments}
|
|
||||||
allowUnarchive
|
|
||||||
allowExcelDownload
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const DefaultDashboard = () => (
|
const DefaultDashboard = () => (
|
||||||
<>
|
<>
|
||||||
{linkedCorporate && (
|
{linkedCorporate && (
|
||||||
@@ -297,10 +163,11 @@ export default function TeacherDashboard({user, linkedCorporate}: Props) {
|
|||||||
!!linkedCorporate && "mt-12 xl:mt-6",
|
!!linkedCorporate && "mt-12 xl:mt-6",
|
||||||
)}>
|
)}>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("students")}
|
onClick={() => router.push("/#students")}
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
Icon={BsPersonFill}
|
Icon={BsPersonFill}
|
||||||
label="Students"
|
label="Students"
|
||||||
value={users.filter(studentFilter).length}
|
value={students.length}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
@@ -312,6 +179,7 @@ export default function TeacherDashboard({user, linkedCorporate}: Props) {
|
|||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsPaperclip}
|
Icon={BsPaperclip}
|
||||||
label="Average Level"
|
label="Average Level"
|
||||||
|
isLoading={isStudentsLoading}
|
||||||
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
@@ -321,11 +189,11 @@ export default function TeacherDashboard({user, linkedCorporate}: Props) {
|
|||||||
label="Groups"
|
label="Groups"
|
||||||
value={groups.filter((x) => x.admin === user.id).length}
|
value={groups.filter((x) => x.admin === user.id).length}
|
||||||
color="purple"
|
color="purple"
|
||||||
onClick={() => setPage("groups")}
|
onClick={() => router.push("/#groups")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
onClick={() => setPage("assignments")}
|
onClick={() => router.push("/#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">
|
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" />
|
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
|
||||||
<span className="flex flex-col gap-1 items-center text-xl">
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
@@ -339,8 +207,7 @@ export default function TeacherDashboard({user, linkedCorporate}: Props) {
|
|||||||
<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
|
{students
|
||||||
.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} />
|
||||||
@@ -350,8 +217,7 @@ export default function TeacherDashboard({user, linkedCorporate}: Props) {
|
|||||||
<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
|
{students
|
||||||
.filter(studentFilter)
|
|
||||||
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<UserDisplay key={x.id} {...x} />
|
<UserDisplay key={x.id} {...x} />
|
||||||
@@ -361,8 +227,7 @@ export default function TeacherDashboard({user, linkedCorporate}: Props) {
|
|||||||
<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 exam count students</span>
|
<span className="p-4">Highest exam count 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
|
{students
|
||||||
.filter(studentFilter)
|
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
|
||||||
@@ -386,22 +251,84 @@ export default function TeacherDashboard({user, linkedCorporate}: Props) {
|
|||||||
loggedInUser={user}
|
loggedInUser={user}
|
||||||
onClose={(shouldReload) => {
|
onClose={(shouldReload) => {
|
||||||
setSelectedUser(undefined);
|
setSelectedUser(undefined);
|
||||||
if (shouldReload) reload();
|
if (shouldReload && selectedUser!.type === "student") reloadStudents();
|
||||||
}}
|
}}
|
||||||
onViewStudents={
|
onViewStudents={
|
||||||
selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
|
selectedUser.type === "corporate" || selectedUser.type === "teacher"
|
||||||
|
? () => {
|
||||||
|
appendUserFilters({
|
||||||
|
id: "view-students",
|
||||||
|
filter: (x: User) => x.type === "student",
|
||||||
|
});
|
||||||
|
appendUserFilters({
|
||||||
|
id: "belongs-to-admin",
|
||||||
|
filter: (x: User) =>
|
||||||
|
groups
|
||||||
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(x.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push("/list/users");
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onViewTeachers={
|
||||||
|
selectedUser.type === "corporate" || selectedUser.type === "student"
|
||||||
|
? () => {
|
||||||
|
appendUserFilters({
|
||||||
|
id: "view-teachers",
|
||||||
|
filter: (x: User) => x.type === "teacher",
|
||||||
|
});
|
||||||
|
appendUserFilters({
|
||||||
|
id: "belongs-to-admin",
|
||||||
|
filter: (x: User) =>
|
||||||
|
groups
|
||||||
|
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
|
||||||
|
.flatMap((g) => g.participants)
|
||||||
|
.includes(x.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push("/list/users");
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
|
|
||||||
user={selectedUser}
|
user={selectedUser}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
</Modal>
|
</Modal>
|
||||||
{page === "students" && <StudentsList />}
|
{router.asPath === "/#students" && (
|
||||||
{page === "groups" && <GroupsList />}
|
<UserList
|
||||||
{page === "assignments" && <AssignmentsPage />}
|
user={user}
|
||||||
{page === "" && <DefaultDashboard />}
|
type="student"
|
||||||
|
renderHeader={(total) => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Students ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{router.asPath === "/#groups" && <GroupsList />}
|
||||||
|
{router.asPath === "/#assignments" && (
|
||||||
|
<AssignmentsPage
|
||||||
|
assignments={assignments}
|
||||||
|
groups={assignmentsGroups}
|
||||||
|
users={assignmentsUsers}
|
||||||
|
user={user}
|
||||||
|
reloadAssignments={reloadAssignments}
|
||||||
|
isLoading={isAssignmentsLoading}
|
||||||
|
onBack={() => router.push("/")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{router.asPath === "/" && <DefaultDashboard />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
233
src/dashboards/views/AssignmentsPage.tsx
Normal file
233
src/dashboards/views/AssignmentsPage.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import { Assignment } from "@/interfaces/results";
|
||||||
|
import { CorporateUser, Group, User } from "@/interfaces/user";
|
||||||
|
import { getUserCompanyName } from "@/resources/user";
|
||||||
|
import {
|
||||||
|
activeAssignmentFilter,
|
||||||
|
archivedAssignmentFilter,
|
||||||
|
futureAssignmentFilter,
|
||||||
|
pastAssignmentFilter,
|
||||||
|
startHasExpiredAssignmentFilter,
|
||||||
|
} from "@/utils/assignments";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { groupBy } from "lodash";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { BsArrowLeft, BsArrowRepeat, BsPlus } from "react-icons/bs";
|
||||||
|
import AssignmentCard from "../AssignmentCard";
|
||||||
|
import AssignmentCreator from "../AssignmentCreator";
|
||||||
|
import AssignmentView from "../AssignmentView";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
assignments: Assignment[];
|
||||||
|
corporateAssignments?: ({ corporate?: CorporateUser } & Assignment)[];
|
||||||
|
groups: Group[];
|
||||||
|
users: User[];
|
||||||
|
isLoading: boolean;
|
||||||
|
user: User;
|
||||||
|
onBack: () => void;
|
||||||
|
reloadAssignments: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssignmentsPage({
|
||||||
|
assignments,
|
||||||
|
corporateAssignments,
|
||||||
|
user,
|
||||||
|
groups,
|
||||||
|
users,
|
||||||
|
isLoading,
|
||||||
|
onBack,
|
||||||
|
reloadAssignments,
|
||||||
|
}: Props) {
|
||||||
|
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
|
||||||
|
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
||||||
|
|
||||||
|
const displayAssignmentView = !!selectedAssignment && !isCreatingAssignment;
|
||||||
|
|
||||||
|
const assignmentsPastExpiredStart = assignments.filter(startHasExpiredAssignmentFilter);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{displayAssignmentView && (
|
||||||
|
<AssignmentView
|
||||||
|
isOpen={displayAssignmentView}
|
||||||
|
onClose={() => {
|
||||||
|
setSelectedAssignment(undefined);
|
||||||
|
setIsCreatingAssignment(false);
|
||||||
|
reloadAssignments();
|
||||||
|
}}
|
||||||
|
assignment={selectedAssignment}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/** I'll be using this is creating assingment as a workaround for a key to trigger a new rendering */}
|
||||||
|
{isCreatingAssignment && (
|
||||||
|
<AssignmentCreator
|
||||||
|
assignment={selectedAssignment}
|
||||||
|
groups={groups}
|
||||||
|
users={users}
|
||||||
|
user={user}
|
||||||
|
isCreating={isCreatingAssignment}
|
||||||
|
cancelCreation={() => {
|
||||||
|
setIsCreatingAssignment(false);
|
||||||
|
setSelectedAssignment(undefined);
|
||||||
|
reloadAssignments();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="w-full flex justify-between items-center">
|
||||||
|
<div
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={reloadAssignments}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
|
<span>Reload</span>
|
||||||
|
<BsArrowRepeat
|
||||||
|
className={clsx("text-xl", isLoading && "animate-spin")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-lg font-bold">Active Assignments Status</span>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span>
|
||||||
|
<b>Total:</b>{" "}
|
||||||
|
{assignments
|
||||||
|
.filter(activeAssignmentFilter)
|
||||||
|
.reduce((acc, curr) => acc + curr.results.length, 0)}
|
||||||
|
/
|
||||||
|
{assignments
|
||||||
|
.filter(activeAssignmentFilter)
|
||||||
|
.reduce((acc, curr) => curr.exams.length + acc, 0)}
|
||||||
|
</span>
|
||||||
|
{Object.keys(
|
||||||
|
groupBy(corporateAssignments, (x) => x.corporate?.id)
|
||||||
|
).map((x) => (
|
||||||
|
<div key={x}>
|
||||||
|
<span className="font-semibold">
|
||||||
|
{getUserCompanyName(
|
||||||
|
users.find((u) => u.id === x)!,
|
||||||
|
users,
|
||||||
|
groups
|
||||||
|
)}
|
||||||
|
:{" "}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{groupBy(corporateAssignments, (x) => x.corporate?.id)[
|
||||||
|
x
|
||||||
|
].reduce((acc, curr) => curr.results.length + acc, 0)}
|
||||||
|
/
|
||||||
|
{groupBy(corporateAssignments, (x) => x.corporate?.id)[
|
||||||
|
x
|
||||||
|
].reduce((acc, curr) => curr.exams.length + acc, 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-2xl font-semibold">
|
||||||
|
Active Assignments (
|
||||||
|
{assignments.filter(activeAssignmentFilter).length})
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{assignments.filter(activeAssignmentFilter).map((a) => (
|
||||||
|
<AssignmentCard
|
||||||
|
{...a}
|
||||||
|
users={users}
|
||||||
|
onClick={() => setSelectedAssignment(a)}
|
||||||
|
key={a.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-2xl font-semibold">
|
||||||
|
Planned Assignments (
|
||||||
|
{assignments.filter(futureAssignmentFilter).length})
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<div
|
||||||
|
onClick={() => setIsCreatingAssignment(true)}
|
||||||
|
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300"
|
||||||
|
>
|
||||||
|
<BsPlus className="text-6xl" />
|
||||||
|
<span className="text-lg">New Assignment</span>
|
||||||
|
</div>
|
||||||
|
{assignments.filter(futureAssignmentFilter).map((a) => (
|
||||||
|
<AssignmentCard
|
||||||
|
{...a}
|
||||||
|
users={users}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedAssignment(a);
|
||||||
|
setIsCreatingAssignment(true);
|
||||||
|
}}
|
||||||
|
key={a.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-2xl font-semibold">
|
||||||
|
Past Assignments ({assignments.filter(pastAssignmentFilter).length})
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{assignments.filter(pastAssignmentFilter).map((a) => (
|
||||||
|
<AssignmentCard
|
||||||
|
{...a}
|
||||||
|
users={users}
|
||||||
|
onClick={() => setSelectedAssignment(a)}
|
||||||
|
key={a.id}
|
||||||
|
allowDownload
|
||||||
|
reload={reloadAssignments}
|
||||||
|
allowArchive
|
||||||
|
allowExcelDownload
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-2xl font-semibold">
|
||||||
|
Assignments start expired ({assignmentsPastExpiredStart.length})
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{assignments.filter(startHasExpiredAssignmentFilter).map((a) => (
|
||||||
|
<AssignmentCard
|
||||||
|
{...a}
|
||||||
|
users={users}
|
||||||
|
onClick={() => setSelectedAssignment(a)}
|
||||||
|
key={a.id}
|
||||||
|
allowDownload
|
||||||
|
reload={reloadAssignments}
|
||||||
|
allowArchive
|
||||||
|
allowExcelDownload
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-2xl font-semibold">
|
||||||
|
Archived Assignments (
|
||||||
|
{assignments.filter(archivedAssignmentFilter).length})
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{assignments.filter(archivedAssignmentFilter).map((a) => (
|
||||||
|
<AssignmentCard
|
||||||
|
{...a}
|
||||||
|
users={users}
|
||||||
|
onClick={() => setSelectedAssignment(a)}
|
||||||
|
key={a.id}
|
||||||
|
allowDownload
|
||||||
|
reload={reloadAssignments}
|
||||||
|
allowUnarchive
|
||||||
|
allowExcelDownload
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -212,7 +212,7 @@ export default function Finish({user, scores, modules, information, solutions, i
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{assignment && !assignment.released && (
|
{assignment && !assignment.released && !isLoading && (
|
||||||
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-12">
|
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-12">
|
||||||
{/* <span className={clsx("loading loading-infinity w-32", moduleColors[selectedModule].progress)} /> */}
|
{/* <span className={clsx("loading loading-infinity w-32", moduleColors[selectedModule].progress)} /> */}
|
||||||
<BsBan size={64} className={clsx(moduleColors[selectedModule].progress)} />
|
<BsBan size={64} className={clsx(moduleColors[selectedModule].progress)} />
|
||||||
@@ -223,7 +223,7 @@ export default function Finish({user, scores, modules, information, solutions, i
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isLoading && false && (
|
{!isLoading && !(assignment && !assignment.released) && (
|
||||||
<div className="mb-20 mt-32 flex w-full items-center justify-between gap-9">
|
<div className="mb-20 mt-32 flex w-full items-center justify-between gap-9">
|
||||||
<span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span>
|
<span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span>
|
||||||
<div className="flex gap-9 px-16">
|
<div className="flex gap-9 px-16">
|
||||||
@@ -296,6 +296,7 @@ export default function Finish({user, scores, modules, information, solutions, i
|
|||||||
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => onViewResults()}
|
onClick={() => onViewResults()}
|
||||||
|
disabled={assignment && !assignment.released}
|
||||||
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||||
<BsEyeFill className="h-7 w-7 text-white" />
|
<BsEyeFill className="h-7 w-7 text-white" />
|
||||||
</button>
|
</button>
|
||||||
@@ -303,6 +304,7 @@ export default function Finish({user, scores, modules, information, solutions, i
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
|
||||||
<button
|
<button
|
||||||
|
disabled={assignment && !assignment.released}
|
||||||
onClick={() => onViewResults(modules.findIndex((x) => x === selectedModule))}
|
onClick={() => onViewResults(modules.findIndex((x) => x === selectedModule))}
|
||||||
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
|
||||||
<BsEyeFill className="h-7 w-7 text-white" />
|
<BsEyeFill className="h-7 w-7 text-white" />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import { LevelPart, UserSolution } from "@/interfaces/exam";
|
import { LevelPart, UserSolution } from "@/interfaces/exam";
|
||||||
|
import clsx from "clsx";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
|
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
|
||||||
|
|
||||||
@@ -21,10 +22,10 @@ const PartDivider: React.FC<Props> = ({ partIndex, part, onNext }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-3/6 h-fit border bg-white rounded-3xl p-12 gap-8">
|
<div className={clsx("flex flex-col h-fit border bg-white rounded-3xl p-12 gap-8", part.intro ? "w-3/6" : "items-center my-auto")}>
|
||||||
{/** only level for now */}
|
{/** 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>
|
<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.intro ? `Part ${partIndex + 1}` : "Placement Test"}</p></div>
|
||||||
{part.intro!.split('\\n\\n').map((x, index) => <p key={`line-${index}`} className="text-2xl text-clip">{x}</p>)}
|
{part.intro && part.intro.split('\\n\\n').map((x, index) => <p key={`line-${index}`} className="text-2xl text-clip" dangerouslySetInnerHTML={{__html: x.replace('that is not correct', 'that is <span class="font-bold"><u>not correct</u></span>')}}></p>)}
|
||||||
<div className="flex items-center justify-center mt-4">
|
<div className="flex items-center justify-center mt-4">
|
||||||
<Button color="purple" onClick={() => onNext()} className="max-w-[200px] self-end w-full text-2xl">
|
<Button color="purple" onClick={() => onNext()} className="max-w-[200px] self-end w-full text-2xl">
|
||||||
{partIndex === 0 ? `Start now`: `Start Part ${partIndex + 1}`}
|
{partIndex === 0 ? `Start now`: `Start Part ${partIndex + 1}`}
|
||||||
|
|||||||
@@ -3,14 +3,31 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
part: LevelPart,
|
part: LevelPart,
|
||||||
contextWord: string | undefined,
|
contextWords: { match: string, originalLine: string }[] | undefined,
|
||||||
setContextWordLine: React.Dispatch<React.SetStateAction<number | undefined>>
|
setContextWordLines: React.Dispatch<React.SetStateAction<number[] | undefined>>
|
||||||
|
setTotalLines: React.Dispatch<React.SetStateAction<number>>
|
||||||
}
|
}
|
||||||
|
|
||||||
const TextComponent: React.FC<Props> = ({ part, contextWord, setContextWordLine }) => {
|
const TextComponent: React.FC<Props> = ({ part, contextWords, setContextWordLines, setTotalLines }) => {
|
||||||
const textRef = useRef<HTMLDivElement>(null);
|
const textRef = useRef<HTMLDivElement>(null);
|
||||||
const [lineNumbers, setLineNumbers] = useState<number[]>([]);
|
const [lineNumbers, setLineNumbers] = useState<number[]>([]);
|
||||||
const [lineHeight, setLineHeight] = useState<number>(0);
|
const [lineHeight, setLineHeight] = useState<number>(0);
|
||||||
|
const [addBreaksTo, setAddBreaksTo] = useState<number[]>([]);
|
||||||
|
|
||||||
|
const getBoldTag = (context: string) => {
|
||||||
|
const regex = /<b\s+class=['"]([^'"]+)['"]>(\d+)<\/b>/;
|
||||||
|
const match = context.match(regex);
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
className: match[1],
|
||||||
|
number: match[2],
|
||||||
|
fullTag: match[0]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bTag = getBoldTag(part.context!);
|
||||||
|
|
||||||
const calculateLineNumbers = () => {
|
const calculateLineNumbers = () => {
|
||||||
if (textRef.current) {
|
if (textRef.current) {
|
||||||
@@ -31,19 +48,53 @@ const TextComponent: React.FC<Props> = ({ part, contextWord, setContextWordLine
|
|||||||
offscreenElement.style.textAlign = computedStyle.textAlign as CanvasTextAlign;
|
offscreenElement.style.textAlign = computedStyle.textAlign as CanvasTextAlign;
|
||||||
|
|
||||||
const textContent = textRef.current.textContent || '';
|
const textContent = textRef.current.textContent || '';
|
||||||
textContent.split(/(\s+)/).forEach((word: string) => {
|
|
||||||
const span = document.createElement('span');
|
const paragraphs = textContent.split(/\n\n/);
|
||||||
span.textContent = word;
|
const betweenParagraphs: string[][] = Array.from({ length: paragraphs.length }, () => []);
|
||||||
offscreenElement.appendChild(span);
|
|
||||||
|
const lines = paragraphs.map((line, lineIndex) => {
|
||||||
|
const paragraphWords = line.split(/(\s+)/);
|
||||||
|
return paragraphWords.map((word, wordIndex) => {
|
||||||
|
if (lineIndex !== 0 && wordIndex == 0 && lineIndex < paragraphs.length) {
|
||||||
|
betweenParagraphs[lineIndex - 1][1] = word;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wordIndex == paragraphWords.length - 1 && lineIndex < paragraphs.length) {
|
||||||
|
betweenParagraphs[lineIndex][0] = word;
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = document.createElement('span');
|
||||||
|
if (wordIndex === 0 && bTag) {
|
||||||
|
const b = document.createElement('b');
|
||||||
|
b.classList.add(bTag.className);
|
||||||
|
b.textContent = `${lineIndex + 1}`;
|
||||||
|
span.appendChild(b);
|
||||||
|
span.appendChild(document.createTextNode(word.substring(1)));
|
||||||
|
}else {
|
||||||
|
span.appendChild(document.createTextNode(word));
|
||||||
|
}
|
||||||
|
return span;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
lines.forEach(line => {
|
||||||
|
line.forEach((span, index) => {
|
||||||
|
offscreenElement.appendChild(span);
|
||||||
|
});
|
||||||
|
offscreenElement.appendChild(document.createElement('br'));
|
||||||
});
|
});
|
||||||
|
|
||||||
document.body.appendChild(offscreenElement);
|
document.body.appendChild(offscreenElement);
|
||||||
|
|
||||||
const lines: string[][] = [[]];
|
const processedLines: string[][] = [[]];
|
||||||
let currentLine = 1;
|
let currentLine = 1;
|
||||||
let currentLineTop: number | undefined;
|
let currentLineTop: number | undefined;
|
||||||
let contextWordLine: number | null = null;
|
|
||||||
|
|
||||||
|
let contextWordLines: number[] = [];
|
||||||
|
if (contextWords) {
|
||||||
|
contextWordLines = Array(contextWords.length).fill(-1);
|
||||||
|
}
|
||||||
const firstChild = offscreenElement.firstChild as HTMLElement;
|
const firstChild = offscreenElement.firstChild as HTMLElement;
|
||||||
if (firstChild) {
|
if (firstChild) {
|
||||||
currentLineTop = firstChild.getBoundingClientRect().top;
|
currentLineTop = firstChild.getBoundingClientRect().top;
|
||||||
@@ -51,25 +102,44 @@ const TextComponent: React.FC<Props> = ({ part, contextWord, setContextWordLine
|
|||||||
|
|
||||||
const spans = offscreenElement.querySelectorAll<HTMLSpanElement>('span');
|
const spans = offscreenElement.querySelectorAll<HTMLSpanElement>('span');
|
||||||
|
|
||||||
spans.forEach(span => {
|
let betweenIndex = 0;
|
||||||
|
const addBreaksTo: number[] = [];
|
||||||
|
spans.forEach((span, index) => {
|
||||||
const rect = span.getBoundingClientRect();
|
const rect = span.getBoundingClientRect();
|
||||||
const top = rect.top;
|
const top = rect.top;
|
||||||
|
|
||||||
|
if (
|
||||||
|
betweenIndex < paragraphs.length - 1 &&
|
||||||
|
span.textContent === betweenParagraphs[betweenIndex][1] &&
|
||||||
|
spans[index - 1].textContent === betweenParagraphs[betweenIndex][0]
|
||||||
|
) {
|
||||||
|
addBreaksTo.push(currentLine);
|
||||||
|
betweenIndex = betweenIndex + 1;
|
||||||
|
}
|
||||||
|
|
||||||
if (currentLineTop !== undefined && top > currentLineTop) {
|
if (currentLineTop !== undefined && top > currentLineTop) {
|
||||||
currentLine++;
|
currentLine++;
|
||||||
currentLineTop = top;
|
currentLineTop = top;
|
||||||
lines.push([]);
|
processedLines.push([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
lines[lines.length - 1].push(span.textContent?.trim() || '');
|
processedLines[processedLines.length - 1].push(span.textContent?.trim() || '');
|
||||||
|
if (contextWords && contextWordLines.some(element => element === -1)) {
|
||||||
if (contextWord && contextWordLine === null && span.textContent?.includes(contextWord)) {
|
contextWords.forEach((w, index) => {
|
||||||
contextWordLine = currentLine;
|
if (span.textContent?.includes(w.match) && contextWordLines[index] == -1) {
|
||||||
|
contextWordLines[index] = currentLine;
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setLineNumbers(lines.map((_, index) => index + 1));
|
|
||||||
if (contextWordLine) {
|
setAddBreaksTo(addBreaksTo);
|
||||||
setContextWordLine(contextWordLine);
|
|
||||||
|
setLineNumbers(processedLines.map((_, index) => index + 1));
|
||||||
|
setTotalLines(currentLine);
|
||||||
|
|
||||||
|
if (contextWordLines.length > 0) {
|
||||||
|
setContextWordLines(contextWordLines);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.body.removeChild(offscreenElement);
|
document.body.removeChild(offscreenElement);
|
||||||
@@ -77,7 +147,6 @@ const TextComponent: React.FC<Props> = ({ part, contextWord, setContextWordLine
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
calculateLineNumbers();
|
calculateLineNumbers();
|
||||||
|
|
||||||
@@ -97,32 +166,19 @@ const TextComponent: React.FC<Props> = ({ part, contextWord, setContextWordLine
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [part.context, contextWord]);
|
}, [part.context, contextWords]);
|
||||||
|
|
||||||
/*if (typeof part.showContextLines === "undefined") {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2 w-full">
|
|
||||||
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
|
||||||
{!!part.context &&
|
|
||||||
part.context
|
|
||||||
.split(/\n|(\\n)/g)
|
|
||||||
.filter((x) => x && x.length > 0 && x !== "\\n")
|
|
||||||
.map((line, index) => (
|
|
||||||
<Fragment key={index}>
|
|
||||||
<p key={index}>{line}</p>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}*/
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex mt-2">
|
<div className="flex mt-2">
|
||||||
<div className="flex-shrink-0 w-8 pr-2">
|
<div className="flex-shrink-0 w-8 pr-2">
|
||||||
{lineNumbers.map(num => (
|
{lineNumbers.map(num => (
|
||||||
<div key={num} className="text-gray-400 flex justify-end" style={{ lineHeight: `${lineHeight}px` }}>
|
<>
|
||||||
{num}
|
<div key={num} className="text-gray-400 flex justify-end" style={{ lineHeight: `${lineHeight}px` }}>
|
||||||
</div>
|
{num}
|
||||||
|
</div>
|
||||||
|
{/* Do not delete the space between the span or else the lines get messed up */}
|
||||||
|
{addBreaksTo.includes(num) && <span className={`h-[${lineHeight}px] whitespace-pre-wrap`}> </span>}
|
||||||
|
</>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div ref={textRef} className="h-fit whitespace-pre-wrap ml-2">
|
<div ref={textRef} className="h-fit whitespace-pre-wrap ml-2">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Button from "@/components/Low/Button";
|
|||||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||||
import { renderSolution } from "@/components/Solutions";
|
import { renderSolution } from "@/components/Solutions";
|
||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import { Exercise, FillBlanksMCOption, LevelExam, MultipleChoiceExercise, MultipleChoiceQuestion, ShuffleMap, UserSolution } from "@/interfaces/exam";
|
import { Exercise, FillBlanksMCOption, LevelExam, MultipleChoiceExercise, UserSolution } from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import { countExercises } from "@/utils/moduleUtils";
|
import { countExercises } from "@/utils/moduleUtils";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@@ -14,6 +14,7 @@ import PartDivider from "./PartDivider";
|
|||||||
import Timer from "@/components/Medium/Timer";
|
import Timer from "@/components/Medium/Timer";
|
||||||
import shuffleExamExercise from "./Shuffle";
|
import shuffleExamExercise from "./Shuffle";
|
||||||
import { Tab } from "@headlessui/react";
|
import { Tab } from "@headlessui/react";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
exam: LevelExam;
|
exam: LevelExam;
|
||||||
@@ -51,7 +52,10 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
|||||||
setCurrentSolution
|
setCurrentSolution
|
||||||
} = useExamStore((state) => state);
|
} = useExamStore((state) => state);
|
||||||
|
|
||||||
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]);
|
// In case client want to switch back
|
||||||
|
const textRenderDisabled = true;
|
||||||
|
|
||||||
|
const [showSubmissionModal, setShowSubmissionModal] = useState(false);
|
||||||
const [showQuestionsModal, setShowQuestionsModal] = useState(false);
|
const [showQuestionsModal, setShowQuestionsModal] = useState(false);
|
||||||
const [continueAnyways, setContinueAnyways] = useState(false);
|
const [continueAnyways, setContinueAnyways] = useState(false);
|
||||||
const [textRender, setTextRender] = useState(false);
|
const [textRender, setTextRender] = useState(false);
|
||||||
@@ -59,7 +63,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
|||||||
const [nextExerciseCalled, setNextExerciseCalled] = useState(false);
|
const [nextExerciseCalled, setNextExerciseCalled] = useState(false);
|
||||||
const [currentSolutionSet, setCurrentSolutionSet] = useState(false);
|
const [currentSolutionSet, setCurrentSolutionSet] = useState(false);
|
||||||
|
|
||||||
const [seenParts, setSeenParts] = useState<number[]>(showSolutions ? exam.parts.map((_, index) => index) : [0]);
|
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.parts.map((_, index) => index) : [0]));
|
||||||
|
|
||||||
const [questionModalKwargs, setQuestionModalKwargs] = useState<{
|
const [questionModalKwargs, setQuestionModalKwargs] = useState<{
|
||||||
type?: "module" | "blankQuestions" | "submit"; unanswered?: boolean | undefined; onClose: (next?: boolean) => void | undefined;
|
type?: "module" | "blankQuestions" | "submit"; unanswered?: boolean | undefined; onClose: (next?: boolean) => void | undefined;
|
||||||
@@ -68,13 +72,22 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
|||||||
onClose: function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } }
|
onClose: function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } }
|
||||||
});
|
});
|
||||||
|
|
||||||
const [currentExercise, setCurrentExercise] = useState<Exercise>(exam.parts[0].exercises[0]);
|
const [currentExercise, setCurrentExercise] = useState<Exercise | undefined>(undefined);
|
||||||
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions);
|
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions);
|
||||||
|
const [startNow, setStartNow] = useState<boolean>(true && !showSolutions);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentExercise === undefined && partIndex === 0 && exerciseIndex === 0) {
|
||||||
|
setCurrentExercise(exam.parts[0].exercises[0]);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentExercise, partIndex, exerciseIndex]);
|
||||||
|
|
||||||
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
||||||
|
|
||||||
const [contextWord, setContextWord] = useState<string | undefined>(undefined);
|
const [contextWords, setContextWords] = useState<{ match: string, originalLine: string }[] | undefined>(undefined);
|
||||||
const [contextWordLine, setContextWordLine] = useState<number | undefined>(undefined);
|
const [contextWordLines, setContextWordLines] = useState<number[] | undefined>(undefined);
|
||||||
|
const [totalLines, setTotalLines] = useState<number>(0);
|
||||||
|
|
||||||
const [showSolutionsSave, setShowSolutionsSave] = useState(showSolutions ? userSolutions.filter((x) => x.module === "level") : undefined)
|
const [showSolutionsSave, setShowSolutionsSave] = useState(showSolutions ? userSolutions.filter((x) => x.module === "level") : undefined)
|
||||||
|
|
||||||
@@ -108,10 +121,9 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
|||||||
let exercise = exam.parts[partIndex]?.exercises[exerciseIndex];
|
let exercise = exam.parts[partIndex]?.exercises[exerciseIndex];
|
||||||
exercise = {
|
exercise = {
|
||||||
...exercise,
|
...exercise,
|
||||||
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
|
userSolutions: userSolutions.find((x) => x.exercise == exercise.id)?.solutions || [],
|
||||||
};
|
};
|
||||||
exercise = shuffleExamExercise(exam.shuffle, exercise, showSolutions, userSolutions, shuffles, setShuffles);
|
exercise = shuffleExamExercise(exam.shuffle, exercise, showSolutions, userSolutions, shuffles, setShuffles);
|
||||||
|
|
||||||
return exercise;
|
return exercise;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -140,29 +152,30 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
|
||||||
if (!answeredEveryQuestion(partIndex) && !continueAnyways && !showSolutions && !seenParts.includes(partIndex + 1)) {
|
if (!answeredEveryQuestion(partIndex) && !continueAnyways && !showSolutions && !seenParts.has(partIndex + 1)) {
|
||||||
modalKwargs();
|
modalKwargs();
|
||||||
setShowQuestionsModal(true);
|
setShowQuestionsModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!showSolutions && exam.parts[0].intro && !seenParts.includes(partIndex + 1)) {
|
if (!showSolutions && exam.parts[0].intro && !seenParts.has(partIndex + 1)) {
|
||||||
setShowPartDivider(true);
|
setShowPartDivider(true);
|
||||||
setBgColor(levelBgColor);
|
setBgColor(levelBgColor);
|
||||||
}
|
}
|
||||||
setSeenParts((prev) => [...prev, partIndex + 1])
|
|
||||||
if (partIndex < exam.parts.length - 1 && exam.parts[partIndex + 1].context) {
|
setSeenParts(prev => new Set(prev).add(partIndex + 1));
|
||||||
|
|
||||||
|
if (partIndex < exam.parts.length - 1 && exam.parts[partIndex + 1].context && !textRenderDisabled) {
|
||||||
setTextRender(true);
|
setTextRender(true);
|
||||||
}
|
}
|
||||||
setPartIndex(partIndex + 1);
|
setPartIndex(partIndex + 1);
|
||||||
setExerciseIndex(0);
|
setExerciseIndex(0);
|
||||||
setQuestionIndex(0);
|
setQuestionIndex(0);
|
||||||
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== currentExercise!.id), { id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : questionIndex }]);
|
|
||||||
setCurrentSolutionSet(false);
|
setCurrentSolutionSet(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(partIndex + 1 === exam.parts.length && exerciseIndex === exam.parts[partIndex].exercises.length - 1 && !continueAnyways) {
|
if (partIndex + 1 === exam.parts.length && exerciseIndex === exam.parts[partIndex].exercises.length - 1 && !continueAnyways) {
|
||||||
modalKwargs();
|
modalKwargs();
|
||||||
setShowQuestionsModal(true);
|
setShowQuestionsModal(true);
|
||||||
}
|
}
|
||||||
@@ -187,13 +200,21 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
|||||||
const previousExercise = (solution?: UserSolution) => {
|
const previousExercise = (solution?: UserSolution) => {
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
|
|
||||||
if (exam.parts[partIndex].context && questionIndex === 0 && !textRender) {
|
if (exam.parts[partIndex].context && questionIndex === 0 && !textRender && !textRenderDisabled) {
|
||||||
setTextRender(true);
|
setTextRender(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (questionIndex == 0) {
|
if (questionIndex == 0) {
|
||||||
setPartIndex(partIndex - 1);
|
setPartIndex(partIndex - 1);
|
||||||
|
if (!seenParts.has(partIndex - 1)) {
|
||||||
|
setBgColor(levelBgColor);
|
||||||
|
setShowPartDivider(true);
|
||||||
|
setQuestionIndex(0);
|
||||||
|
setSeenParts(prev => new Set(prev).add(partIndex - 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const lastExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1;
|
const lastExerciseIndex = exam.parts[partIndex - 1].exercises.length - 1;
|
||||||
const lastExercise = exam.parts[partIndex - 1].exercises[lastExerciseIndex];
|
const lastExercise = exam.parts[partIndex - 1].exercises[lastExerciseIndex];
|
||||||
setExerciseIndex(lastExerciseIndex);
|
setExerciseIndex(lastExerciseIndex);
|
||||||
@@ -214,47 +235,17 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
|||||||
if (previousExercise.type === "multipleChoice") {
|
if (previousExercise.type === "multipleChoice") {
|
||||||
setQuestionIndex(previousExercise.questions.length - 1)
|
setQuestionIndex(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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateExerciseIndex = () => {
|
const calculateExerciseIndex = () => {
|
||||||
if (exam.parts[0].intro) {
|
return exam.parts.reduce((acc, curr, index) => {
|
||||||
return exam.parts.reduce((acc, curr, index) => {
|
if (index < partIndex) {
|
||||||
if (index < partIndex) {
|
return acc + countExercises(curr.exercises)
|
||||||
return acc + countExercises(curr.exercises)
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, 0) + (questionIndex + 1);
|
|
||||||
} else {
|
|
||||||
if (partIndex === 0) {
|
|
||||||
return (
|
|
||||||
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + questionIndex //+ multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
|
return acc;
|
||||||
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
|
}, 0) + (questionIndex + 1);
|
||||||
return (
|
|
||||||
exercisesDone +
|
|
||||||
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
|
|
||||||
questionIndex
|
|
||||||
+ multipleChoicesDone.reduce((acc, curr) => { return acc + curr.amount }, 0)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderText = () => (
|
const renderText = () => (
|
||||||
@@ -262,7 +253,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
|||||||
<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={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">
|
<div className="flex flex-col w-full gap-2">
|
||||||
{textRender ? (
|
{textRender && !textRenderDisabled ? (
|
||||||
<>
|
<>
|
||||||
<h4 className="text-xl font-semibold">
|
<h4 className="text-xl font-semibold">
|
||||||
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
||||||
@@ -278,13 +269,14 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
|||||||
{exam.parts[partIndex].context &&
|
{exam.parts[partIndex].context &&
|
||||||
<TextComponent
|
<TextComponent
|
||||||
part={exam.parts[partIndex]}
|
part={exam.parts[partIndex]}
|
||||||
contextWord={contextWord}
|
contextWords={contextWords}
|
||||||
setContextWordLine={setContextWordLine}
|
setContextWordLines={setContextWordLines}
|
||||||
|
setTotalLines={setTotalLines}
|
||||||
/>}
|
/>}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
</div>
|
</div>
|
||||||
{textRender && (
|
{textRender && !textRenderDisabled && (
|
||||||
<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
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
@@ -304,27 +296,34 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
|||||||
);
|
);
|
||||||
|
|
||||||
const partLabel = () => {
|
const partLabel = () => {
|
||||||
|
const partCategory = exam.parts[partIndex].category ? ` (${exam.parts[partIndex].category})` : '';
|
||||||
if (currentExercise?.type === "fillBlanks" && typeCheckWordsMC(currentExercise.words))
|
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}`
|
return `Part ${partIndex + 1} (Questions ${currentExercise.words[0].id} - ${currentExercise.words[currentExercise.words.length - 1].id})${partCategory}\n\n${currentExercise.prompt}`
|
||||||
|
|
||||||
if (currentExercise?.type === "multipleChoice") {
|
if (currentExercise?.type === "multipleChoice") {
|
||||||
return `Part ${partIndex + 1} (Questions ${currentExercise.questions[0].id} - ${currentExercise.questions[currentExercise.questions.length - 1].id})\n\n${currentExercise.prompt}`
|
return `Part ${partIndex + 1} (Questions ${currentExercise.questions[0].id} - ${currentExercise.questions[currentExercise.questions.length - 1].id})${partCategory}\n\n${currentExercise.prompt}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof exam.parts[partIndex].context === "string") {
|
if (typeof exam.parts[partIndex].context === "string") {
|
||||||
const nextExercise = exam.parts[partIndex].exercises[0] as MultipleChoiceExercise;
|
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}`
|
return `Part ${partIndex + 1} (Questions ${nextExercise.questions[0].id} - ${nextExercise.questions[nextExercise.questions.length - 1].id})${partCategory}\n\n${nextExercise.prompt}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const answeredEveryQuestion = (partIndex: number) => {
|
const answeredEveryQuestion = (partIndex: number) => {
|
||||||
return exam.parts[partIndex].exercises.every((exercise) => {
|
return exam.parts[partIndex].exercises.every((exercise) => {
|
||||||
const userSolution = userSolutions.find(x => x.exercise === exercise.id);
|
const userSolution = userSolutions.find(x => x.exercise === exercise.id);
|
||||||
if (exercise.type === "multipleChoice") {
|
switch (exercise.type) {
|
||||||
return userSolution?.solutions.length === exercise.questions.length;
|
case 'multipleChoice':
|
||||||
}
|
return userSolution?.solutions.length === exercise.questions.length;
|
||||||
if (exercise.type === "fillBlanks") {
|
case 'fillBlanks':
|
||||||
return userSolution?.solutions.length === exercise.words.length;
|
return userSolution?.solutions.length === exercise.words.length;
|
||||||
|
case 'writeBlanks':
|
||||||
|
return userSolution?.solutions.length === exercise.solutions.length;
|
||||||
|
case 'matchSentences':
|
||||||
|
return userSolution?.solutions.length === exercise.sentences.length;
|
||||||
|
case 'trueFalse':
|
||||||
|
return userSolution?.solutions.length === exercise.questions.length;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
@@ -332,35 +331,59 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
|
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
|
||||||
|
|
||||||
|
const findMatch = (index: number) => {
|
||||||
|
if (currentExercise && currentExercise.type === "multipleChoice" && currentExercise!.questions[index]) {
|
||||||
|
const match = currentExercise!.questions[index].prompt.match(regex);
|
||||||
|
if (match) {
|
||||||
|
return { match: match[1], originalLine: match[2] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the client for some whatever random reason decides
|
||||||
|
// to add more questions update this
|
||||||
|
const numberOfQuestions = 2;
|
||||||
|
|
||||||
|
if (exam.parts[partIndex].context) {
|
||||||
|
const hits = Array.from({ length: numberOfQuestions }).reduce<{ match: string, originalLine: string }[]>((acc, _, i) => {
|
||||||
|
const result = findMatch(questionIndex + i);
|
||||||
|
if (!!result) {
|
||||||
|
acc.push(result);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (hits.length > 0) {
|
||||||
|
setContextWords(hits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentExercise, questionIndex, totalLines]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
exerciseIndex !== -1 && currentExercise &&
|
exerciseIndex !== -1 && currentExercise &&
|
||||||
currentExercise.type === "multipleChoice" &&
|
currentExercise.type === "multipleChoice" &&
|
||||||
currentExercise.questions[questionIndex] &&
|
exam.parts[partIndex].context && contextWordLines
|
||||||
currentExercise.questions[questionIndex].prompt &&
|
|
||||||
exam.parts[partIndex].context
|
|
||||||
) {
|
) {
|
||||||
const match = currentExercise.questions[questionIndex].prompt.match(regex);
|
if (contextWordLines.length > 0) {
|
||||||
if (match) {
|
contextWordLines.forEach((n, i) => {
|
||||||
const word = match[1];
|
if (contextWords && contextWords[i] && n !== -1) {
|
||||||
const originalLineNumber = match[2];
|
const updatedPrompt = currentExercise!.questions[questionIndex + i].prompt.replace(
|
||||||
|
`in line ${contextWords[i].originalLine}`,
|
||||||
if (word !== contextWord) {
|
`in line ${n}`
|
||||||
setContextWord(word);
|
);
|
||||||
}
|
currentExercise!.questions[questionIndex + i].prompt = updatedPrompt;
|
||||||
|
}
|
||||||
const updatedPrompt = currentExercise.questions[questionIndex].prompt.replace(
|
})
|
||||||
`in line ${originalLineNumber}`,
|
|
||||||
`in line ${contextWordLine || originalLineNumber}`
|
|
||||||
);
|
|
||||||
|
|
||||||
currentExercise.questions[questionIndex].prompt = updatedPrompt;
|
|
||||||
setChangedPrompt(true);
|
setChangedPrompt(true);
|
||||||
} else {
|
|
||||||
setContextWord(undefined);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [contextWordLines]);
|
||||||
}, [currentExercise, questionIndex, contextWordLine]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (continueAnyways) {
|
if (continueAnyways) {
|
||||||
@@ -380,7 +403,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
|||||||
if (partIndex === exam.parts.length - 1) {
|
if (partIndex === exam.parts.length - 1) {
|
||||||
kwargs.type = "submit"
|
kwargs.type = "submit"
|
||||||
kwargs.unanswered = !exam.parts.every((_, partIndex) => answeredEveryQuestion(partIndex));
|
kwargs.unanswered = !exam.parts.every((_, partIndex) => answeredEveryQuestion(partIndex));
|
||||||
kwargs.onClose = function (x: boolean | undefined) { if (x) { setContinueAnyways(true); setShowQuestionsModal(false); } else { setShowQuestionsModal(false) } };
|
kwargs.onClose = function (x: boolean | undefined) { if (x) { setShowSubmissionModal(true); setShowQuestionsModal(false); } else { setShowQuestionsModal(false) } };
|
||||||
}
|
}
|
||||||
setQuestionModalKwargs(kwargs);
|
setQuestionModalKwargs(kwargs);
|
||||||
}
|
}
|
||||||
@@ -400,13 +423,13 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
|||||||
setChangedPrompt(false);
|
setChangedPrompt(false);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{textRender ?
|
{textRender && !textRenderDisabled ?
|
||||||
renderText() :
|
renderText() :
|
||||||
<>
|
<>
|
||||||
{exam.parts[partIndex].context && renderText()}
|
{exam.parts[partIndex].context && renderText()}
|
||||||
{(showSolutions || editing) ?
|
{(showSolutions || editing) ?
|
||||||
renderSolution(currentExercise, nextExercise, previousExercise) :
|
currentExercise && renderSolution(currentExercise, nextExercise, previousExercise) :
|
||||||
renderExercise(currentExercise, exam.id, next, previousExercise)
|
currentExercise && renderExercise(currentExercise, exam.id, next, previousExercise)
|
||||||
}
|
}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -417,12 +440,31 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={clsx("flex flex-col h-full w-full gap-8 items-center", showPartDivider && "justify-center")}>
|
<div className={clsx("flex flex-col h-full w-full gap-8 items-center", showPartDivider && "justify-center")}>
|
||||||
|
<Modal
|
||||||
|
className={"!w-2/6 !p-8"}
|
||||||
|
titleClassName={"font-bold text-3xl text-mti-rose-light"}
|
||||||
|
isOpen={showSubmissionModal}
|
||||||
|
onClose={() => { }}
|
||||||
|
title={"Confirm Submission"}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<p className="text-xl mt-8 mb-12">Are you sure you want to proceed with the submission?</p>
|
||||||
|
<div className="w-full flex justify-between">
|
||||||
|
<Button color="purple" onClick={() => setShowSubmissionModal(false)} variant="outline" className="max-w-[200px] self-end w-full !text-xl">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button color="rose" onClick={() => { setShowSubmissionModal(false); setContinueAnyways(true) }} className="max-w-[200px] self-end w-full !text-xl">
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</Modal>
|
||||||
<QuestionsModal isOpen={showQuestionsModal} {...questionModalKwargs} />
|
<QuestionsModal isOpen={showQuestionsModal} {...questionModalKwargs} />
|
||||||
{
|
{
|
||||||
!(partIndex === 0 && questionIndex === 0 && showPartDivider) &&
|
!(partIndex === 0 && questionIndex === 0 && (showPartDivider || startNow)) &&
|
||||||
<Timer minTimer={exam.minTimer} disableTimer={showSolutions} standalone={true} />
|
<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") }} /> : (
|
{(showPartDivider || startNow) ? <PartDivider part={exam.parts[partIndex]} partIndex={partIndex} onNext={() => { setShowPartDivider(false); setStartNow(false); setBgColor("bg-white"); }} /> : (
|
||||||
<>
|
<>
|
||||||
{exam.parts[0].intro && (
|
{exam.parts[0].intro && (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@@ -430,20 +472,27 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
|||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
|
||||||
{exam.parts.map((_, index) =>
|
{exam.parts.map((_, index) =>
|
||||||
<Tab key={index} onClick={(e) => {
|
<Tab key={index} onClick={(e) => {
|
||||||
if (!seenParts.includes(index)) {
|
/*
|
||||||
e.preventDefault();
|
// If client wants to revert uncomment and remove the added if statement
|
||||||
} else {
|
if (!seenParts.has(index)) {
|
||||||
setExerciseIndex(0);
|
e.preventDefault();
|
||||||
setQuestionIndex(0);
|
} else {
|
||||||
|
*/
|
||||||
|
setExerciseIndex(0);
|
||||||
|
setQuestionIndex(0);
|
||||||
|
if (!seenParts.has(index)) {
|
||||||
|
setShowPartDivider(true);
|
||||||
|
setBgColor(levelBgColor);
|
||||||
|
setSeenParts(prev => new Set(prev).add(index));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={({ selected }) =>
|
className={({ selected }) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-level/80",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-level/80",
|
||||||
"ring-white ring-opacity-60 focus:outline-none",
|
"ring-white ring-opacity-60 focus:outline-none",
|
||||||
"transition duration-300 ease-in-out",
|
"transition duration-300 ease-in-out hover:bg-white/70",
|
||||||
selected && "bg-white shadow",
|
selected && "bg-white shadow",
|
||||||
seenParts.includes(index) ? "hover:bg-white/70" : "cursor-not-allowed"
|
// seenParts.includes(index) ? "hover:bg-white/70" : "cursor-not-allowed"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>{`Part ${index + 1}`}</Tab>
|
>{`Part ${index + 1}`}</Tab>
|
||||||
@@ -454,6 +503,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ModuleTitle
|
<ModuleTitle
|
||||||
|
examLabel={exam.label}
|
||||||
partLabel={partLabel()}
|
partLabel={partLabel()}
|
||||||
minTimer={exam.minTimer}
|
minTimer={exam.minTimer}
|
||||||
exerciseIndex={calculateExerciseIndex()}
|
exerciseIndex={calculateExerciseIndex()}
|
||||||
@@ -470,33 +520,6 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
|||||||
)}>
|
)}>
|
||||||
{memoizedRender}
|
{memoizedRender}
|
||||||
</div>
|
</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" && questionIndex === 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>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {initializeApp} from "firebase/app";
|
import {initializeApp} from "firebase/app";
|
||||||
import * as admin from "firebase-admin/app";
|
import * as admin from "firebase-admin/app";
|
||||||
import {getStorage} from "firebase/storage";
|
import {getStorage} from "firebase/storage";
|
||||||
|
import { base64 } from "@firebase/util";
|
||||||
|
|
||||||
const stagingServiceAccount = require("@/constants/staging.json");
|
const stagingServiceAccount = require("@/constants/staging.json");
|
||||||
const platformServiceAccount = require("@/constants/platform.json");
|
const platformServiceAccount = require("@/constants/platform.json");
|
||||||
@@ -22,3 +23,10 @@ export const adminApp = admin.initializeApp(
|
|||||||
Math.random().toString(),
|
Math.random().toString(),
|
||||||
);
|
);
|
||||||
export const storage = getStorage(app);
|
export const storage = getStorage(app);
|
||||||
|
|
||||||
|
export const firebaseAuthScryptParams = {
|
||||||
|
memCost: Number(process.env.FIREBASE_SCRYPT_MEM_COST),
|
||||||
|
rounds: Number(process.env.FIREBASE_SCRYPT_ROUNDS),
|
||||||
|
saltSeparator: process.env.FIREBASE_SCRYPT_B64_SALT_SEPARATOR!,
|
||||||
|
signerKey: process.env.FIREBASE_SCRYPT_B64_SIGNER_KEY!,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import {Assignment} from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
import axios from "axios";
|
import Axios from "axios";
|
||||||
|
import {setupCache} from "axios-cache-interceptor";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
|
||||||
|
const instance = Axios.create();
|
||||||
|
const axios = setupCache(instance);
|
||||||
|
|
||||||
export default function useAssignments({assigner, assignees, corporate}: {assigner?: string; assignees?: string; corporate?: 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);
|
||||||
@@ -13,7 +17,7 @@ export default function useAssignments({assigner, assignees, corporate}: {assign
|
|||||||
.get<Assignment[]>(!corporate ? "/api/assignments" : `/api/assignments/corporate/${corporate}`)
|
.get<Assignment[]>(!corporate ? "/api/assignments" : `/api/assignments/corporate/${corporate}`)
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
if (assigner) {
|
if (assigner) {
|
||||||
setAssignments(response.data.filter((a) => a.assigner === assigner));
|
setAssignments(response.data.filter((a) => a.assigner === assigner || (!a.teachers ? false : a.teachers.includes(assigner))));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import {Group, User} from "@/interfaces/user";
|
import {Group, User} from "@/interfaces/user";
|
||||||
import axios from "axios";
|
import Axios from "axios";
|
||||||
|
import {setupCache} from "axios-cache-interceptor";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
|
||||||
|
const instance = Axios.create();
|
||||||
|
const axios = setupCache(instance);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
admin?: string;
|
admin?: string;
|
||||||
userType?: string;
|
userType?: string;
|
||||||
|
|||||||
@@ -1,18 +1,6 @@
|
|||||||
import {useState, useMemo} from "react";
|
import {useState, useMemo} from "react";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
|
import { search } from "@/utils/search";
|
||||||
/*fields example = [
|
|
||||||
['id'],
|
|
||||||
['companyInformation', 'companyInformation', 'name']
|
|
||||||
]*/
|
|
||||||
|
|
||||||
const getFieldValue = (fields: string[], data: any): string => {
|
|
||||||
if (fields.length === 0) return data;
|
|
||||||
const [key, ...otherFields] = fields;
|
|
||||||
|
|
||||||
if (data[key]) return getFieldValue(otherFields, data[key]);
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useListSearch<T>(fields: string[][], rows: T[]) {
|
export function useListSearch<T>(fields: string[][], rows: T[]) {
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
@@ -20,22 +8,11 @@ export function useListSearch<T>(fields: string[][], rows: T[]) {
|
|||||||
const renderSearch = () => <Input label="Search" type="text" name="search" onChange={setText} placeholder="Enter search text" value={text} />;
|
const renderSearch = () => <Input label="Search" type="text" name="search" onChange={setText} placeholder="Enter search text" value={text} />;
|
||||||
|
|
||||||
const updatedRows = useMemo(() => {
|
const updatedRows = useMemo(() => {
|
||||||
const searchText = text.toLowerCase();
|
return search(text, fields, rows);
|
||||||
return rows.filter((row) => {
|
|
||||||
return fields.some((fieldsKeys) => {
|
|
||||||
const value = getFieldValue(fieldsKeys, row);
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value.toLowerCase().includes(searchText);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === "number") {
|
|
||||||
return (value as Number).toString().includes(searchText);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [fields, rows, text]);
|
}, [fields, rows, text]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
text,
|
||||||
rows: updatedRows,
|
rows: updatedRows,
|
||||||
renderSearch,
|
renderSearch,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import {Exam} from "@/interfaces/exam";
|
import {Exam} from "@/interfaces/exam";
|
||||||
import {Permission, PermissionType} from "@/interfaces/permissions";
|
import {Permission, PermissionType} from "@/interfaces/permissions";
|
||||||
import {ExamState} from "@/stores/examStore";
|
import {ExamState} from "@/stores/examStore";
|
||||||
import axios from "axios";
|
import Axios from "axios";
|
||||||
|
import {setupCache} from "axios-cache-interceptor";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
|
||||||
|
const instance = Axios.create();
|
||||||
|
const axios = setupCache(instance);
|
||||||
|
|
||||||
export default function usePermissions(user: string) {
|
export default function usePermissions(user: string) {
|
||||||
const [permissions, setPermissions] = useState<PermissionType[]>([]);
|
const [permissions, setPermissions] = useState<PermissionType[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import {Exam} from "@/interfaces/exam";
|
import {Exam} from "@/interfaces/exam";
|
||||||
import {ExamState} from "@/stores/examStore";
|
import {ExamState} from "@/stores/examStore";
|
||||||
import axios from "axios";
|
import Axios from "axios";
|
||||||
|
import {setupCache} from "axios-cache-interceptor";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
|
||||||
|
const instance = Axios.create();
|
||||||
|
const axios = setupCache(instance);
|
||||||
|
|
||||||
export type Session = ExamState & {user: string; id: string; date: string};
|
export type Session = ExamState & {user: string; id: string; date: string};
|
||||||
|
|
||||||
export default function useSessions(user?: string) {
|
export default function useSessions(user?: string) {
|
||||||
|
|||||||
@@ -1,21 +1,58 @@
|
|||||||
import {User} from "@/interfaces/user";
|
import {Type, User} from "@/interfaces/user";
|
||||||
import axios from "axios";
|
import Axios from "axios";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
|
import {setupCache} from "axios-cache-interceptor";
|
||||||
|
const instance = Axios.create();
|
||||||
|
const axios = setupCache(instance);
|
||||||
|
|
||||||
export default function useUsers() {
|
export const userHashStudent = { type: "student" } as { type: Type };
|
||||||
|
export const userHashTeacher = { type: "teacher" } as { type: Type };
|
||||||
|
export const userHashCorporate = { type: "corporate" } as { type: Type };
|
||||||
|
|
||||||
|
export default function useUsers(props?: {type?: Type; page?: number; size?: number}) {
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isError, setIsError] = useState(false);
|
const [isError, setIsError] = useState(false);
|
||||||
|
const [latestID, setLatestID] = useState<string>();
|
||||||
|
const [firstID, setFirstID] = useState<string>();
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
|
||||||
const getData = () => {
|
const getData = () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (!!props)
|
||||||
|
Object.keys(props).forEach((key) => {
|
||||||
|
if (!!props[key as keyof typeof props]) params.append(key, props[key as keyof typeof props]!.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!!latestID) params.append("latestID", latestID);
|
||||||
|
if (!!firstID) params.append("firstID", firstID);
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
axios
|
axios
|
||||||
.get<User[]>("/api/users/list", {headers: {page: "register"}})
|
.get<{users: User[]; total: number}>(`/api/users/list?${params.toString()}`, {headers: {page: "register"}})
|
||||||
.then((response) => setUsers(response.data))
|
.then((response) => {
|
||||||
|
setUsers(response.data.users);
|
||||||
|
setTotal(response.data.total);
|
||||||
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(getData, []);
|
const next = () => {
|
||||||
|
setLatestID(users[users.length - 1]?.id);
|
||||||
|
setFirstID(undefined);
|
||||||
|
setPage((prev) => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
return {users, isLoading, isError, reload: getData};
|
const previous = () => {
|
||||||
|
setLatestID(undefined);
|
||||||
|
setFirstID(page > 1 ? users[0]?.id : undefined);
|
||||||
|
setPage((prev) => prev - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
useEffect(getData, [props, latestID, firstID]);
|
||||||
|
|
||||||
|
return {users, total, page, isLoading, isError, reload: getData, next, previous};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ interface ExamBase {
|
|||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
difficulty?: Difficulty;
|
difficulty?: Difficulty;
|
||||||
|
owners?: string[];
|
||||||
shuffle?: boolean;
|
shuffle?: boolean;
|
||||||
createdBy?: string; // option as it has been added later
|
createdBy?: string; // option as it has been added later
|
||||||
createdAt?: string; // option as it has been added later
|
createdAt?: string; // option as it has been added later
|
||||||
private?: boolean;
|
private?: boolean;
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
export interface ReadingExam extends ExamBase {
|
export interface ReadingExam extends ExamBase {
|
||||||
module: "reading";
|
module: "reading";
|
||||||
@@ -39,6 +41,7 @@ export interface LevelExam extends ExamBase {
|
|||||||
export interface LevelPart {
|
export interface LevelPart {
|
||||||
context?: string;
|
context?: string;
|
||||||
intro?: string;
|
intro?: string;
|
||||||
|
category?: string;
|
||||||
exercises: Exercise[];
|
exercises: Exercise[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,14 @@ export interface Assignment {
|
|||||||
instructorGender?: InstructorGender;
|
instructorGender?: InstructorGender;
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
|
teachers?: string[];
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
released?: boolean;
|
released?: boolean;
|
||||||
// unless start is active, the assignment is not visible to the assignees
|
// unless start is active, the assignment is not visible to the assignees
|
||||||
// start date now works as a limit time to start the exam
|
// start date now works as a limit time to start the exam
|
||||||
start?: boolean;
|
start?: boolean;
|
||||||
|
autoStartDate?: Date;
|
||||||
|
autoStart?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AssignmentWithCorporateId = Assignment & {corporateId: string};
|
export type AssignmentWithCorporateId = Assignment & {corporateId: string};
|
||||||
|
|||||||
@@ -164,4 +164,4 @@ export interface Code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent" | "mastercorporate";
|
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent" | "mastercorporate";
|
||||||
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent", "mastercorporate"];
|
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent", "mastercorporate"];
|
||||||
@@ -125,7 +125,7 @@ export default function BatchCreateUser({user, users, permissions, onFinish}: Pr
|
|||||||
demographicInformation: {
|
demographicInformation: {
|
||||||
country: countryItem?.countryCode,
|
country: countryItem?.countryCode,
|
||||||
passport_id: passport_id?.toString().trim() || undefined,
|
passport_id: passport_id?.toString().trim() || undefined,
|
||||||
phone,
|
phone: phone.toString(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -161,7 +161,7 @@ export default function BatchCreateUser({user, users, permissions, onFinish}: Pr
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const newUser of newUsers) await axios.post("/api/make_user", {...newUser, type, expiryDate});
|
await axios.post("/api/batch_users", { users: newUsers.map(user => ({...user, type, expiryDate})) });
|
||||||
toast.success(`Successfully added ${newUsers.length} user(s)!`);
|
toast.success(`Successfully added ${newUsers.length} user(s)!`);
|
||||||
onFinish();
|
onFinish();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ export default function CorporateGradingSystem({user, defaultSteps, mutate}: {us
|
|||||||
if (areStepsOverlapped(steps)) return toast.error("There seems to be an overlap in one of your steps.");
|
if (areStepsOverlapped(steps)) return toast.error("There seems to be an overlap in one of your steps.");
|
||||||
if (
|
if (
|
||||||
steps.reduce((acc, curr) => {
|
steps.reduce((acc, curr) => {
|
||||||
console.log(acc - (curr.max - curr.min + 1));
|
|
||||||
return acc - (curr.max - curr.min + 1);
|
return acc - (curr.max - curr.min + 1);
|
||||||
}, 100) > 0
|
}, 100) > 0
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {useMemo} from "react";
|
import {useMemo, useState} from "react";
|
||||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
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";
|
||||||
@@ -11,11 +11,15 @@ 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, uniq} from "lodash";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {BsBan, BsBanFill, BsCheck, BsCircle, BsStop, BsTrash, BsUpload, BsX} from "react-icons/bs";
|
import {BsBan, BsBanFill, BsCheck, BsCircle, BsPencil, BsStop, BsTrash, BsUpload, BsX} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import {useListSearch} from "@/hooks/useListSearch";
|
import {useListSearch} from "@/hooks/useListSearch";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import {checkAccess} from "@/utils/permissions";
|
||||||
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
|
||||||
const searchFields = [["module"], ["id"], ["createdBy"]];
|
const searchFields = [["module"], ["id"], ["createdBy"]];
|
||||||
|
|
||||||
@@ -29,9 +33,40 @@ const CLASSES: {[key in Module]: string} = {
|
|||||||
|
|
||||||
const columnHelper = createColumnHelper<Exam>();
|
const columnHelper = createColumnHelper<Exam>();
|
||||||
|
|
||||||
|
const ExamOwnerSelector = ({options, exam, onSave}: {options: User[]; exam: Exam; onSave: (owners: string[]) => void}) => {
|
||||||
|
const [owners, setOwners] = useState(exam.owners || []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-4 mt-4">
|
||||||
|
{options.map((c) => (
|
||||||
|
<Button
|
||||||
|
variant={owners.includes(c.id) ? "solid" : "outline"}
|
||||||
|
onClick={() => setOwners((prev) => (prev.includes(c.id) ? prev.filter((x) => x !== c.id) : [...prev, c.id]))}
|
||||||
|
className="max-w-[200px] w-full"
|
||||||
|
key={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => onSave(owners)} className="w-full max-w-[200px] self-end">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default function ExamList({user}: {user: User}) {
|
export default function ExamList({user}: {user: User}) {
|
||||||
|
const [selectedExam, setSelectedExam] = useState<Exam>();
|
||||||
|
|
||||||
const {exams, reload} = useExams();
|
const {exams, reload} = useExams();
|
||||||
const {users} = useUsers();
|
const {users} = useUsers();
|
||||||
|
const {groups} = useGroups({admin: user?.id, userType: user?.type});
|
||||||
|
|
||||||
|
const filteredCorporates = useMemo(() => {
|
||||||
|
const participantsAndAdmins = uniq(groups.flatMap((x) => [...x.participants, x.admin])).filter((x) => x !== user?.id);
|
||||||
|
return users.filter((x) => participantsAndAdmins.includes(x.id) && x.type === "corporate");
|
||||||
|
}, [users, groups, user]);
|
||||||
|
|
||||||
const parsedExams = useMemo(() => {
|
const parsedExams = useMemo(() => {
|
||||||
return exams.map((exam) => {
|
return exams.map((exam) => {
|
||||||
@@ -94,6 +129,29 @@ export default function ExamList({user}: {user: User}) {
|
|||||||
.finally(reload);
|
.finally(reload);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateExam = async (exam: Exam, body: object) => {
|
||||||
|
if (!confirm(`Are you sure you want to update this ${capitalize(exam.module)} exam?`)) return;
|
||||||
|
|
||||||
|
axios
|
||||||
|
.patch(`/api/exam/${exam.module}/${exam.id}`, body)
|
||||||
|
.then(() => toast.success(`Updated the "${exam.id}" exam`))
|
||||||
|
.catch((reason) => {
|
||||||
|
if (reason.response.status === 404) {
|
||||||
|
toast.error("Exam not found!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reason.response.status === 403) {
|
||||||
|
toast.error("You do not have permission to update this exam!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error("Something went wrong, please try again later.");
|
||||||
|
})
|
||||||
|
.finally(reload)
|
||||||
|
.finally(() => setSelectedExam(undefined));
|
||||||
|
};
|
||||||
|
|
||||||
const deleteExam = async (exam: Exam) => {
|
const deleteExam = async (exam: Exam) => {
|
||||||
if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return;
|
if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return;
|
||||||
|
|
||||||
@@ -166,12 +224,21 @@ export default function ExamList({user}: {user: User}) {
|
|||||||
cell: ({row}: {row: {original: Exam}}) => {
|
cell: ({row}: {row: {original: Exam}}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<button
|
{(row.original.owners?.includes(user.id) || checkAccess(user, ["admin", "developer"])) && (
|
||||||
data-tip={row.original.private ? "Set as public" : "Set as private"}
|
<>
|
||||||
onClick={async () => await privatizeExam(row.original)}
|
<button
|
||||||
className="cursor-pointer tooltip">
|
data-tip={row.original.private ? "Set as public" : "Set as private"}
|
||||||
{row.original.private ? <BsCircle /> : <BsBan />}
|
onClick={async () => await privatizeExam(row.original)}
|
||||||
</button>
|
className="cursor-pointer tooltip">
|
||||||
|
{row.original.private ? <BsCircle /> : <BsBan />}
|
||||||
|
</button>
|
||||||
|
{checkAccess(user, ["admin", "developer", "mastercorporate"]) && (
|
||||||
|
<button data-tip="Edit owners" onClick={() => setSelectedExam(row.original)} className="cursor-pointer tooltip">
|
||||||
|
<BsPencil />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
data-tip="Load exam"
|
data-tip="Load exam"
|
||||||
className="cursor-pointer tooltip"
|
className="cursor-pointer tooltip"
|
||||||
@@ -198,6 +265,13 @@ export default function ExamList({user}: {user: User}) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 w-full h-full">
|
<div className="flex flex-col gap-4 w-full h-full">
|
||||||
{renderSearch()}
|
{renderSearch()}
|
||||||
|
<Modal isOpen={!!selectedExam} title={`Edit Exam Owners - ${selectedExam?.id}`} onClose={() => setSelectedExam(undefined)}>
|
||||||
|
{!!selectedExam ? (
|
||||||
|
<ExamOwnerSelector options={filteredCorporates} exam={selectedExam} onSave={(owners) => updateExam(selectedExam, {owners})} />
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||||
<thead>
|
<thead>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
|||||||
@@ -173,14 +173,13 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
|||||||
control: (styles) => ({
|
control: (styles) => ({
|
||||||
...styles,
|
...styles,
|
||||||
backgroundColor: "white",
|
backgroundColor: "white",
|
||||||
borderRadius: "999px",
|
|
||||||
padding: "1rem 1.5rem",
|
padding: "1rem 1.5rem",
|
||||||
zIndex: "40",
|
zIndex: "40",
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{user.type !== "teacher" && (
|
{user.type !== "teacher" && (
|
||||||
<Button className="w-full max-w-[300px]" onClick={openFilePicker} isLoading={isLoading} variant="outline">
|
<Button className="w-full max-w-[300px] h-fit" onClick={openFilePicker} isLoading={isLoading} variant="outline">
|
||||||
{filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name}
|
{filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -204,6 +203,7 @@ 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 [viewingAllParticipants, setViewingAllParticipants] = useState<string>();
|
||||||
|
|
||||||
const {permissions} = usePermissions(user?.id || "");
|
const {permissions} = usePermissions(user?.id || "");
|
||||||
|
|
||||||
@@ -254,11 +254,29 @@ export default function GroupList({user}: {user: User}) {
|
|||||||
}),
|
}),
|
||||||
columnHelper.accessor("participants", {
|
columnHelper.accessor("participants", {
|
||||||
header: "Participants",
|
header: "Participants",
|
||||||
cell: (info) =>
|
cell: (info) => (
|
||||||
info
|
<span>
|
||||||
.getValue()
|
{info
|
||||||
.map((x) => users.find((y) => y.id === x)?.name)
|
.getValue()
|
||||||
.join(", "),
|
.slice(0, viewingAllParticipants === info.row.original.id ? undefined : 5)
|
||||||
|
.map((x) => users.find((y) => y.id === x)?.name)
|
||||||
|
.join(", ")}
|
||||||
|
{info.getValue().length > 5 && viewingAllParticipants !== info.row.original.id && (
|
||||||
|
<button
|
||||||
|
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||||
|
onClick={() => setViewingAllParticipants(info.row.original.id)}>
|
||||||
|
, View More
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{info.getValue().length > 5 && viewingAllParticipants === info.row.original.id && (
|
||||||
|
<button
|
||||||
|
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
|
||||||
|
onClick={() => setViewingAllParticipants(undefined)}>
|
||||||
|
, View Less
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
header: "",
|
header: "",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import axios from "axios";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize, reverse} from "lodash";
|
import {capitalize, reverse} from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {Fragment, useEffect, useState} from "react";
|
import {Fragment, useEffect, useState, useMemo} from "react";
|
||||||
import {BsArrowDown, BsArrowDownUp, BsArrowUp, BsCheck, BsCheckCircle, BsEye, BsFillExclamationOctagonFill, BsPerson, BsTrash} from "react-icons/bs";
|
import {BsArrowDown, BsArrowDownUp, BsArrowUp, BsCheck, BsCheckCircle, BsEye, BsFillExclamationOctagonFill, BsPerson, BsTrash} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import {countries, TCountries} from "countries-list";
|
import {countries, TCountries} from "countries-list";
|
||||||
@@ -46,10 +46,12 @@ const CompanyNameCell = ({users, user, groups}: {user: User; users: User[]; grou
|
|||||||
export default function UserList({
|
export default function UserList({
|
||||||
user,
|
user,
|
||||||
filters = [],
|
filters = [],
|
||||||
|
type,
|
||||||
renderHeader,
|
renderHeader,
|
||||||
}: {
|
}: {
|
||||||
user: User;
|
user: User;
|
||||||
filters?: ((user: User) => boolean)[];
|
filters?: ((user: User) => boolean)[];
|
||||||
|
type?: Type;
|
||||||
renderHeader?: (total: number) => JSX.Element;
|
renderHeader?: (total: number) => JSX.Element;
|
||||||
}) {
|
}) {
|
||||||
const [showDemographicInformation, setShowDemographicInformation] = useState(false);
|
const [showDemographicInformation, setShowDemographicInformation] = useState(false);
|
||||||
@@ -57,7 +59,12 @@ export default function UserList({
|
|||||||
const [displayUsers, setDisplayUsers] = useState<User[]>([]);
|
const [displayUsers, setDisplayUsers] = useState<User[]>([]);
|
||||||
const [selectedUser, setSelectedUser] = useState<User>();
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
|
|
||||||
const {users, reload} = useUsers();
|
const userHash = useMemo(() => ({
|
||||||
|
type,
|
||||||
|
size: 25,
|
||||||
|
}), [type])
|
||||||
|
|
||||||
|
const {users, page, total, reload, next, previous} = useUsers(userHash);
|
||||||
const {permissions} = usePermissions(user?.id || "");
|
const {permissions} = usePermissions(user?.id || "");
|
||||||
const {balance} = useUserBalance();
|
const {balance} = useUserBalance();
|
||||||
const {groups} = useGroups({
|
const {groups} = useGroups({
|
||||||
@@ -80,19 +87,15 @@ export default function UserList({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (user && users) {
|
if (users && users.length > 0) {
|
||||||
const filterUsers = ["corporate", "teacher", "mastercorporate"].includes(user.type)
|
const filteredUsers = filters.reduce((d, f) => d.filter(f), users);
|
||||||
? users.filter((u) => groups.flatMap((g) => g.participants).includes(u.id))
|
|
||||||
: users;
|
|
||||||
|
|
||||||
const filteredUsers = filters.reduce((d, f) => d.filter(f), filterUsers);
|
|
||||||
const sortedUsers = await asyncSorter<User>(filteredUsers, sortFunction);
|
const sortedUsers = await asyncSorter<User>(filteredUsers, sortFunction);
|
||||||
|
|
||||||
setDisplayUsers([...sortedUsers]);
|
setDisplayUsers([...sortedUsers]);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user, users, sorter, groups]);
|
}, [users, sorter]);
|
||||||
|
|
||||||
const deleteAccount = (user: User) => {
|
const deleteAccount = (user: User) => {
|
||||||
if (!confirm(`Are you sure you want to delete ${user.name}'s account?`)) return;
|
if (!confirm(`Are you sure you want to delete ${user.name}'s account?`)) return;
|
||||||
@@ -604,7 +607,7 @@ export default function UserList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{renderHeader && renderHeader(displayUsers.length)}
|
{renderHeader && renderHeader(total)}
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}>
|
<Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}>
|
||||||
{selectedUser && renderUserCard(selectedUser)}
|
{selectedUser && renderUserCard(selectedUser)}
|
||||||
@@ -616,6 +619,14 @@ export default function UserList({
|
|||||||
Download List
|
Download List
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-full flex gap-2 justify-between">
|
||||||
|
<Button className="w-full max-w-[200px]" disabled={page === 0} onClick={previous}>
|
||||||
|
Previous Page
|
||||||
|
</Button>
|
||||||
|
<Button className="w-full max-w-[200px]" disabled={page * 25 >= total} onClick={next}>
|
||||||
|
Next Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||||
<thead>
|
<thead>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export default function UserCreator({user, users, permissions, onFinish}: Props)
|
|||||||
if (!name || name.trim().length === 0) return toast.error("Please enter a valid name!");
|
if (!name || name.trim().length === 0) return toast.error("Please enter a valid name!");
|
||||||
if (!email || email.trim().length === 0) return toast.error("Please enter a valid e-mail address!");
|
if (!email || email.trim().length === 0) return toast.error("Please enter a valid e-mail address!");
|
||||||
if (users.map((x) => x.email).includes(email.trim())) return toast.error("That e-mail is already in use!");
|
if (users.map((x) => x.email).includes(email.trim())) return toast.error("That e-mail is already in use!");
|
||||||
if (!password || password.trim().length === 0) return toast.error("Please enter a valid password!");
|
if (!password || password.trim().length < 6) return toast.error("Please enter a valid password!");
|
||||||
if (password !== confirmPassword) return toast.error("The passwords do not match!");
|
if (password !== confirmPassword) return toast.error("The passwords do not match!");
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -199,21 +199,23 @@ export default function UserCreator({user, users, permissions, onFinish}: Props)
|
|||||||
<Input type="text" name="department" label="Department" onChange={setPosition} value={position} placeholder="Department" />
|
<Input type="text" name="department" label="Department" onChange={setPosition} value={position} placeholder="Department" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
{!(type === "corporate" && user.type === "corporate") && (
|
||||||
className={clsx(
|
<div
|
||||||
"flex flex-col gap-4",
|
className={clsx(
|
||||||
(!["student", "teacher"].includes(type) || ["corporate", "teacher"].includes(user?.type)) &&
|
"flex flex-col gap-4",
|
||||||
!["corporate", "mastercorporate"].includes(type) &&
|
(!["student", "teacher"].includes(type) || ["corporate", "teacher"].includes(user?.type)) &&
|
||||||
"col-span-2",
|
!["corporate", "mastercorporate"].includes(type) &&
|
||||||
)}>
|
"col-span-2",
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Group</label>
|
)}>
|
||||||
<Select
|
<label className="font-normal text-base text-mti-gray-dim">Group</label>
|
||||||
options={groups
|
<Select
|
||||||
.filter((x) => (!selectedCorporate ? true : x.admin === selectedCorporate))
|
options={groups
|
||||||
.map((g) => ({value: g.id, label: g.name}))}
|
.filter((x) => (!selectedCorporate ? true : x.admin === selectedCorporate))
|
||||||
onChange={(e) => setGroup(e?.value || undefined)}
|
.map((g) => ({value: g.id, label: g.name}))}
|
||||||
/>
|
onChange={(e) => setGroup(e?.value || undefined)}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ export default function ExamPage({page, user}: Props) {
|
|||||||
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
|
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
|
||||||
const nextExam = exams[moduleIndex];
|
const nextExam = exams[moduleIndex];
|
||||||
|
|
||||||
if (partIndex === -1 && nextExam.module !== "listening") setPartIndex(0);
|
if (partIndex === -1 && nextExam?.module !== "listening") setPartIndex(0);
|
||||||
if (exerciseIndex === -1 && !["reading", "listening"].includes(nextExam?.module)) setExerciseIndex(0);
|
if (exerciseIndex === -1 && !["reading", "listening"].includes(nextExam?.module)) setExerciseIndex(0);
|
||||||
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
|
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
|
||||||
}
|
}
|
||||||
@@ -281,7 +281,7 @@ export default function ExamPage({page, user}: Props) {
|
|||||||
}, [statsAwaitingEvaluation]);
|
}, [statsAwaitingEvaluation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (exam && exam.module === "level" && exam.parts[0].intro && !showSolutions) setBgColor("bg-ielts-level-light");
|
if (exam && exam.module === "level" && !showSolutions) setBgColor("bg-ielts-level-light");
|
||||||
}, [exam, showSolutions, setBgColor]);
|
}, [exam, showSolutions, setBgColor]);
|
||||||
|
|
||||||
const checkIfStatsHaveBeenEvaluated = (ids: string[]) => {
|
const checkIfStatsHaveBeenEvaluated = (ids: string[]) => {
|
||||||
|
|||||||
@@ -15,31 +15,38 @@ import {
|
|||||||
Exercise,
|
Exercise,
|
||||||
} from "@/interfaces/exam";
|
} from "@/interfaces/exam";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import {getExamById} from "@/utils/exams";
|
import { getExamById } from "@/utils/exams";
|
||||||
import {playSound} from "@/utils/sound";
|
import { playSound } from "@/utils/sound";
|
||||||
import {Tab} from "@headlessui/react";
|
import { Tab } from "@headlessui/react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize, sample} from "lodash";
|
import { capitalize, sample } from "lodash";
|
||||||
import {useRouter} from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {BsArrowRepeat, BsCheck, BsPencilSquare, BsX} from "react-icons/bs";
|
import { BsArrowRepeat, BsCheck, BsPencilSquare, BsX } from "react-icons/bs";
|
||||||
import reactStringReplace from "react-string-replace";
|
import reactStringReplace from "react-string-replace";
|
||||||
import {toast} from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import {v4} from "uuid";
|
import { v4 } from "uuid";
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
[key: string]: any;
|
||||||
|
value: string | null;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||||
const TYPES: {[key: string]: string} = {
|
const TYPES: { [key: string]: string } = {
|
||||||
multiple_choice_4: "Multiple Choice",
|
multiple_choice_4: "Multiple Choice",
|
||||||
multiple_choice_blank_space: "Multiple Choice - Blank Space",
|
multiple_choice_blank_space: "Multiple Choice - Blank Space",
|
||||||
multiple_choice_underlined: "Multiple Choice - Underlined",
|
multiple_choice_underlined: "Multiple Choice - Underlined",
|
||||||
blank_space_text: "Blank Space",
|
blank_space_text: "Blank Space",
|
||||||
reading_passage_utas: "Reading Passage",
|
reading_passage_utas: "Reading Passage",
|
||||||
|
fill_blanks_mc: "Multiple Choice - Fill Blanks",
|
||||||
};
|
};
|
||||||
|
|
||||||
type LevelSection = {type: string; quantity: number; topic?: string; part?: LevelPart};
|
type LevelSection = { type: string; quantity: number; topic?: string; part?: LevelPart };
|
||||||
|
|
||||||
const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion; onUpdate: (question: MultipleChoiceQuestion) => void}) => {
|
const QuestionDisplay = ({ question, onUpdate }: { question: MultipleChoiceQuestion; onUpdate: (question: MultipleChoiceQuestion) => void }) => {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [options, setOptions] = useState(question.options);
|
const [options, setOptions] = useState(question.options);
|
||||||
const [answer, setAnswer] = useState(question.solution);
|
const [answer, setAnswer] = useState(question.solution);
|
||||||
@@ -70,7 +77,7 @@ const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion
|
|||||||
<input
|
<input
|
||||||
defaultValue={option.text}
|
defaultValue={option.text}
|
||||||
className="w-60"
|
className="w-60"
|
||||||
onChange={(e) => setOptions((prev) => prev.map((x, idx) => (idx === index ? {...x, text: e.target.value} : x)))}
|
onChange={(e) => setOptions((prev) => prev.map((x, idx) => (idx === index ? { ...x, text: e.target.value } : x)))}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span>{option.text}</span>
|
<span>{option.text}</span>
|
||||||
@@ -90,7 +97,7 @@ const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onUpdate({...question, options, solution: answer});
|
onUpdate({ ...question, options, solution: answer });
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
}}
|
}}
|
||||||
className="p-2 border border-neutral-300 bg-white rounded-xl hover:drop-shadow transition ease-in-out duration-300">
|
className="p-2 border border-neutral-300 bg-white rounded-xl hover:drop-shadow transition ease-in-out duration-300">
|
||||||
@@ -108,9 +115,16 @@ const QuestionDisplay = ({question, onUpdate}: {question: MultipleChoiceQuestion
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (section: LevelSection) => void}) => {
|
const TaskTab = ({ section, label, index, setSection }: { section: LevelSection; label: string, index: number, setSection: (section: LevelSection) => void }) => {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const [category, setCategory] = useState<string>("");
|
||||||
|
const [description, setDescription] = useState<string>("");
|
||||||
|
const [customDescription, setCustomDescription] = useState<string>("");
|
||||||
|
const [previousOption, setPreviousOption] = useState<Option>({ value: "None", label: "None" });
|
||||||
|
const [descriptionOption, setDescriptionOption] = useState<Option>({ value: "None", label: "None" });
|
||||||
|
const [updateIntro, setUpdateIntro] = useState<boolean>(false);
|
||||||
|
|
||||||
const onUpdate = (question: MultipleChoiceQuestion) => {
|
const onUpdate = (question: MultipleChoiceQuestion) => {
|
||||||
if (!section) return;
|
if (!section) return;
|
||||||
|
|
||||||
@@ -124,6 +138,66 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
|
|||||||
setSection(updatedExam as any);
|
setSection(updatedExam as any);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultPresets: any = {
|
||||||
|
multiple_choice_4: "Welcome to {part} of the {label}. In this section, you'll be asked to select the correct word or group of words that best completes each sentence.\n\nFor each question, carefully read the sentence and click on the option (A, B, C, or D) that you believe is correct. After making your selection, you can proceed to the next question by clicking \"Next\". If you need to review or change your previous answers, you can go back at any time by clicking \"Back\".",
|
||||||
|
multiple_choice_blank_space: undefined,
|
||||||
|
multiple_choice_underlined: "Welcome to {part} of the {label}. In this section, you'll be asked to identify the underlined word or group of words that is not correct in each sentence.\n\nFor each question, carefully review the sentence and click on the option (A, B, C, or D) that you believe contains the incorrect word or group of words. After making your selection, you can proceed to the next question by clicking \"Next\". If needed, you can go back to previous questions by clicking \"Back\".",
|
||||||
|
blank_space_text: undefined,
|
||||||
|
reading_passage_utas: "Welcome to {part} of the {label}. In this section, you will read a text and answer the questions that follow.\n\nCarefully read the provided text, then select the correct answer (A, B, C, or D) for each question. After making your selection, you can proceed to the next question by clicking \"Next\". If you need to review or change your answers, you can go back at any time by clicking \"Back\".",
|
||||||
|
fill_blanks_mc: "Welcome to {part} of the {label}. In this section, you will read a text and choose the correct word to fill in each blank space.\n\nFor each question, carefully read the text and click on the option that you believe best fits the context."
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDefaultPreset = () => {
|
||||||
|
return defaultPresets[section.type] ? defaultPresets[section.type].replace('{part}', `Part ${index + 1}`).replace('{label}', label) :
|
||||||
|
"No default preset is yet available for this type of exercise."
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (descriptionOption.value === "Default" && section?.type) {
|
||||||
|
setDescription(getDefaultPreset())
|
||||||
|
}
|
||||||
|
if (descriptionOption.value === "Custom" && customDescription !== "") {
|
||||||
|
setDescription(customDescription);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [descriptionOption, section?.type, label])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (section?.type) {
|
||||||
|
const defaultPreset = getDefaultPreset();
|
||||||
|
if (descriptionOption.value === "Default" && previousOption.value === "Default" && description !== defaultPreset) {
|
||||||
|
setDescriptionOption({ value: "Custom", label: "Custom" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [descriptionOption, description, label])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPreviousOption(descriptionOption);
|
||||||
|
}, [descriptionOption])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (section?.part && ((descriptionOption.value === "Custom" || descriptionOption.value === "Default") && !section.part.intro)) {
|
||||||
|
setUpdateIntro(true);
|
||||||
|
}
|
||||||
|
}, [section?.part, descriptionOption, category])
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (updateIntro && section.part) {
|
||||||
|
setSection({
|
||||||
|
...section,
|
||||||
|
part: {
|
||||||
|
...section.part!,
|
||||||
|
intro: descriptionOption.value === "Default" ? getDefaultPreset() : (descriptionOption.value === "Custom" ? customDescription : undefined),
|
||||||
|
category: category === "" ? undefined : category
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setUpdateIntro(false);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [updateIntro, section?.part])
|
||||||
|
|
||||||
const renderExercise = (exercise: Exercise) => {
|
const renderExercise = (exercise: Exercise) => {
|
||||||
if (exercise.type === "multipleChoice")
|
if (exercise.type === "multipleChoice")
|
||||||
return (
|
return (
|
||||||
@@ -138,7 +212,12 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
|
|||||||
updateExercise={(data: any) =>
|
updateExercise={(data: any) =>
|
||||||
setSection({
|
setSection({
|
||||||
...section,
|
...section,
|
||||||
part: {...section.part!, exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x))},
|
part: {
|
||||||
|
...section.part!,
|
||||||
|
exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? { ...x, ...data } : x)),
|
||||||
|
intro: descriptionOption.value === "Default" ? getDefaultPreset() : (descriptionOption.value === "Custom" ? customDescription : undefined),
|
||||||
|
category: category === "" ? undefined : category
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -158,7 +237,12 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
|
|||||||
updateExercise={(data: any) =>
|
updateExercise={(data: any) =>
|
||||||
setSection({
|
setSection({
|
||||||
...section,
|
...section,
|
||||||
part: {...section.part!, exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x))},
|
part: {
|
||||||
|
...section.part!,
|
||||||
|
exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? { ...x, ...data } : x)),
|
||||||
|
intro: descriptionOption.value === "Default" ? getDefaultPreset() : (descriptionOption.value === "Custom" ? customDescription : undefined),
|
||||||
|
category: category === "" ? undefined : category
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -178,7 +262,12 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
|
|||||||
updateExercise={(data: any) =>
|
updateExercise={(data: any) =>
|
||||||
setSection({
|
setSection({
|
||||||
...section,
|
...section,
|
||||||
part: {...section.part!, exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? {...x, ...data} : x))},
|
part: {
|
||||||
|
...section.part!,
|
||||||
|
exercises: section.part!.exercises.map((x) => (x.id === exercise.id ? { ...x, ...data } : x)),
|
||||||
|
intro: descriptionOption.value === "Default" ? getDefaultPreset() : (descriptionOption.value === "Custom" ? customDescription : undefined),
|
||||||
|
category: category === "" ? undefined : category
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -188,30 +277,61 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Panel className="w-full bg-ielts-level/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
|
<Tab.Panel className="w-full bg-ielts-level/20 min-h-[600px] h-full rounded-xl p-6 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-row w-full gap-4">
|
||||||
|
<div className="flex flex-col gap-3 w-1/2">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Description</label>
|
||||||
|
<Select
|
||||||
|
options={["None", "Default", "Custom"].map((descriptionOption) => ({ value: descriptionOption, label: descriptionOption }))}
|
||||||
|
onChange={(o) => setDescriptionOption({ value: o!.value, label: o!.label })}
|
||||||
|
value={descriptionOption}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 w-1/2">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Category</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Category"
|
||||||
|
name="category"
|
||||||
|
onChange={(e) => setCategory(e)}
|
||||||
|
roundness="full"
|
||||||
|
defaultValue={category}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{descriptionOption.value !== "None" && (
|
||||||
|
<Input
|
||||||
|
type="textarea"
|
||||||
|
placeholder="Part Description"
|
||||||
|
name="category"
|
||||||
|
onChange={(e) => { setDescription(e); setCustomDescription(e); }}
|
||||||
|
roundness="full"
|
||||||
|
value={descriptionOption.value === "Default" ? description : customDescription}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex gap-4 w-full">
|
<div className="flex gap-4 w-full">
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Exercise Type</label>
|
<label className="font-normal text-base text-mti-gray-dim">Exercise Type</label>
|
||||||
<Select
|
<Select
|
||||||
options={Object.keys(TYPES).map((key) => ({value: key, label: TYPES[key]}))}
|
options={Object.keys(TYPES).map((key) => ({ value: key, label: TYPES[key] }))}
|
||||||
onChange={(e) => setSection({...section, type: e!.value!})}
|
onChange={(e) => setSection({ ...section, type: e!.value! })}
|
||||||
value={{value: section?.type || "multiple_choice_4", label: TYPES[section?.type || "multiple_choice_4"]}}
|
value={{ value: section?.type || "multiple_choice_4", label: TYPES[section?.type || "multiple_choice_4"] }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Number of Questions</label>
|
<label className="font-normal text-base text-mti-gray-dim">{section?.type && section.type === "fill_blanks_mc" ? "Number of Words" : "Number of Questions"}</label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
name="Number of Questions"
|
name="Number of Questions"
|
||||||
onChange={(v) => setSection({...section, quantity: parseInt(v)})}
|
onChange={(v) => setSection({ ...section, quantity: parseInt(v) })}
|
||||||
value={section?.quantity || 10}
|
value={section?.quantity || 10}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{section?.type === "reading_passage_utas" && (
|
{section?.type === "reading_passage_utas" || section?.type === "fill_blanks_mc" && (
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Topic</label>
|
<label className="font-normal text-base text-mti-gray-dim">Topic</label>
|
||||||
<Input type="text" name="Topic" onChange={(v) => setSection({...section, topic: v})} value={section?.topic} />
|
<Input type="text" name="Topic" onChange={(v) => setSection({ ...section, topic: v })} value={section?.topic} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -235,18 +355,19 @@ interface Props {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LevelGeneration = ({id}: Props) => {
|
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>();
|
||||||
const [timer, setTimer] = useState(10);
|
const [timer, setTimer] = useState(10);
|
||||||
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
|
||||||
const [numberOfParts, setNumberOfParts] = useState(1);
|
const [numberOfParts, setNumberOfParts] = useState(1);
|
||||||
const [parts, setParts] = useState<LevelSection[]>([{quantity: 10, type: "multiple_choice_4"}]);
|
const [parts, setParts] = useState<LevelSection[]>([{ quantity: 10, type: "multiple_choice_4" }]);
|
||||||
const [isPrivate, setPrivate] = useState<boolean>(false);
|
const [isPrivate, setPrivate] = useState<boolean>(false);
|
||||||
|
const [label, setLabel] = useState<string>("Placement Test");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setParts((prev) => Array.from(Array(numberOfParts)).map((_, i) => (!!prev.at(i) ? prev.at(i)! : {quantity: 10, type: "multiple_choice_4"})));
|
setParts((prev) => Array.from(Array(numberOfParts)).map((_, i) => (!!prev.at(i) ? prev.at(i)! : { quantity: 10, type: "multiple_choice_4" })));
|
||||||
}, [numberOfParts]);
|
}, [numberOfParts]);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -289,7 +410,7 @@ const LevelGeneration = ({id}: Props) => {
|
|||||||
let newParts = [...parts];
|
let newParts = [...parts];
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post<{exercises: {[key: string]: any}}>("/api/exam/level/generate/level", {nr_exercises: numberOfParts, ...body})
|
.post<{ exercises: { [key: string]: any } }>("/api/exam/level/generate/level", { nr_exercises: numberOfParts, ...body })
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
console.log(result.data);
|
console.log(result.data);
|
||||||
|
|
||||||
@@ -304,6 +425,7 @@ const LevelGeneration = ({id}: Props) => {
|
|||||||
variant: "full",
|
variant: "full",
|
||||||
isDiagnostic: false,
|
isDiagnostic: false,
|
||||||
private: isPrivate,
|
private: isPrivate,
|
||||||
|
label: label,
|
||||||
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;
|
||||||
@@ -317,23 +439,55 @@ const LevelGeneration = ({id}: Props) => {
|
|||||||
id: v4(),
|
id: v4(),
|
||||||
prompt:
|
prompt:
|
||||||
part.type === "multiple_choice_underlined"
|
part.type === "multiple_choice_underlined"
|
||||||
? "Select the wrong part of the sentence."
|
? "Choose the underlined word or group of words that is not correct.\nFor each question, select your choice (A, B, C or D)."
|
||||||
: "Select the appropriate option.",
|
: "Choose the correct word or group of words that completes the sentences below.\nFor each question, select the correct letter (A, B, C or D).",
|
||||||
questions: currentExercise.questions.map((x: any) => ({...x, variant: "text"})),
|
questions: currentExercise.questions.map((x: any) => ({ ...x, variant: "text" })),
|
||||||
type: "multipleChoice",
|
type: "multipleChoice",
|
||||||
userSolutions: [],
|
userSolutions: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const item = {
|
const item = {
|
||||||
exercises: [exercise],
|
exercises: [exercise],
|
||||||
|
intro: parts[index].part?.intro,
|
||||||
|
category: parts[index].part?.category
|
||||||
};
|
};
|
||||||
|
|
||||||
newParts = newParts.map((p, i) =>
|
newParts = newParts.map((p, i) =>
|
||||||
i === index
|
i === index
|
||||||
? {
|
? {
|
||||||
...p,
|
...p,
|
||||||
part: item,
|
part: item,
|
||||||
}
|
}
|
||||||
|
: p,
|
||||||
|
);
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type === "fill_blanks_mc") {
|
||||||
|
const exercise: FillBlanksExercise = {
|
||||||
|
id: v4(),
|
||||||
|
prompt: "Read the text below and choose the correct word for each space.\nFor each question, select your choice (A, B, C or D). ",
|
||||||
|
text: currentExercise.text,
|
||||||
|
words: currentExercise.words,
|
||||||
|
solutions: currentExercise.solutions,
|
||||||
|
type: "fillBlanks",
|
||||||
|
variant: "mc",
|
||||||
|
userSolutions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
exercises: [exercise],
|
||||||
|
intro: parts[index].part?.intro,
|
||||||
|
category: parts[index].part?.category
|
||||||
|
};
|
||||||
|
|
||||||
|
newParts = newParts.map((p, i) =>
|
||||||
|
i === index
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
part: item,
|
||||||
|
}
|
||||||
: p,
|
: p,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -341,28 +495,28 @@ const LevelGeneration = ({id}: Props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (part.type === "blank_space_text") {
|
if (part.type === "blank_space_text") {
|
||||||
console.log({currentExercise});
|
|
||||||
|
|
||||||
const exercise: WriteBlanksExercise = {
|
const exercise: WriteBlanksExercise = {
|
||||||
id: v4(),
|
id: v4(),
|
||||||
prompt: "Complete the text below.",
|
prompt: "Complete the text below.",
|
||||||
text: currentExercise.text,
|
text: currentExercise.text,
|
||||||
maxWords: 3,
|
maxWords: 3,
|
||||||
solutions: currentExercise.words.map((x: any) => ({id: x.id, solution: [x.text]})),
|
solutions: currentExercise.words.map((x: any) => ({ id: x.id, solution: [x.text] })),
|
||||||
type: "writeBlanks",
|
type: "writeBlanks",
|
||||||
userSolutions: [],
|
userSolutions: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const item = {
|
const item = {
|
||||||
exercises: [exercise],
|
exercises: [exercise],
|
||||||
|
intro: parts[index].part?.intro,
|
||||||
|
category: parts[index].part?.category
|
||||||
};
|
};
|
||||||
|
|
||||||
newParts = newParts.map((p, i) =>
|
newParts = newParts.map((p, i) =>
|
||||||
i === index
|
i === index
|
||||||
? {
|
? {
|
||||||
...p,
|
...p,
|
||||||
part: item,
|
part: item,
|
||||||
}
|
}
|
||||||
: p,
|
: p,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -372,7 +526,7 @@ const LevelGeneration = ({id}: Props) => {
|
|||||||
const mcExercise: MultipleChoiceExercise = {
|
const mcExercise: MultipleChoiceExercise = {
|
||||||
id: v4(),
|
id: v4(),
|
||||||
prompt: "Select the appropriate option.",
|
prompt: "Select the appropriate option.",
|
||||||
questions: currentExercise.exercises.multipleChoice.questions.map((x: any) => ({...x, variant: "text"})),
|
questions: currentExercise.exercises.multipleChoice.questions.map((x: any) => ({ ...x, variant: "text" })),
|
||||||
type: "multipleChoice",
|
type: "multipleChoice",
|
||||||
userSolutions: [],
|
userSolutions: [],
|
||||||
};
|
};
|
||||||
@@ -393,14 +547,16 @@ const LevelGeneration = ({id}: Props) => {
|
|||||||
const item = {
|
const item = {
|
||||||
context: currentExercise.text.content,
|
context: currentExercise.text.content,
|
||||||
exercises: [mcExercise, wbExercise],
|
exercises: [mcExercise, wbExercise],
|
||||||
|
intro: parts[index].part?.intro,
|
||||||
|
category: parts[index].part?.category
|
||||||
};
|
};
|
||||||
|
|
||||||
newParts = newParts.map((p, i) =>
|
newParts = newParts.map((p, i) =>
|
||||||
i === index
|
i === index
|
||||||
? {
|
? {
|
||||||
...p,
|
...p,
|
||||||
part: item,
|
part: item,
|
||||||
}
|
}
|
||||||
: p,
|
: p,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -434,10 +590,27 @@ const LevelGeneration = ({id}: Props) => {
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
parts.forEach((part) => {
|
||||||
|
part.part!.exercises.forEach((exercise, i) => {
|
||||||
|
switch(exercise.type) {
|
||||||
|
case 'fillBlanks':
|
||||||
|
exercise.prompt.replaceAll('\n', '\\n')
|
||||||
|
break;
|
||||||
|
case 'multipleChoice':
|
||||||
|
exercise.prompt.replaceAll('\n', '\\n')
|
||||||
|
break;
|
||||||
|
case 'writeBlanks':
|
||||||
|
exercise.prompt.replaceAll('\n', '\\n')
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
const exam = {
|
const exam = {
|
||||||
...generatedExam,
|
...generatedExam,
|
||||||
id,
|
id,
|
||||||
parts: generatedExam.parts.map((p, i) => ({...p, exercises: parts[i].part!.exercises})),
|
label: label,
|
||||||
|
parts: generatedExam.parts.map((p, i) => ({ ...p, exercises: parts[i].part!.exercises, category: parts[i].part?.category, intro: parts[i].part?.intro?.replaceAll('\n', '\\n') })),
|
||||||
};
|
};
|
||||||
|
|
||||||
axios
|
axios
|
||||||
@@ -468,7 +641,7 @@ const LevelGeneration = ({id}: Props) => {
|
|||||||
label: capitalize(x),
|
label: capitalize(x),
|
||||||
}))}
|
}))}
|
||||||
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
onChange={(value) => (value ? setDifficulty(value.value as Difficulty) : null)}
|
||||||
value={{value: difficulty, label: capitalize(difficulty)}}
|
value={{ value: difficulty, label: capitalize(difficulty) }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 w-1/3">
|
<div className="flex flex-col gap-3 w-1/3">
|
||||||
@@ -486,12 +659,24 @@ const LevelGeneration = ({id}: Props) => {
|
|||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Exam Label *</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Label"
|
||||||
|
name="label"
|
||||||
|
onChange={(e) => setLabel(e)}
|
||||||
|
roundness="xl"
|
||||||
|
defaultValue={label}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
|
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">
|
||||||
{Array.from(Array(numberOfParts), (_, index) => index).map((index) => (
|
{Array.from(Array(numberOfParts), (_, index) => index).map((index) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={index}
|
key={index}
|
||||||
className={({selected}) =>
|
className={({ selected }) =>
|
||||||
clsx(
|
clsx(
|
||||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-level/70",
|
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-level/70",
|
||||||
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-level focus:outline-none focus:ring-2",
|
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-level focus:outline-none focus:ring-2",
|
||||||
@@ -507,6 +692,8 @@ const LevelGeneration = ({id}: Props) => {
|
|||||||
{Array.from(Array(numberOfParts), (_, index) => index).map((index) => (
|
{Array.from(Array(numberOfParts), (_, index) => index).map((index) => (
|
||||||
<TaskTab
|
<TaskTab
|
||||||
key={index}
|
key={index}
|
||||||
|
label={label}
|
||||||
|
index={index}
|
||||||
section={parts[index]}
|
section={parts[index]}
|
||||||
setSection={(part) => {
|
setSection={(part) => {
|
||||||
console.log(part);
|
console.log(part);
|
||||||
|
|||||||
@@ -1,68 +1,37 @@
|
|||||||
// 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 { withIronSessionApiRoute } from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import { getAllAssignersByCorporate } from "@/utils/groups.be";
|
import {getAssignmentsByAssigner, getAssignmentsForCorporates} from "@/utils/assignments.be";
|
||||||
import { getAssignmentsByAssigners } from "@/utils/assignments.be";
|
|
||||||
|
|
||||||
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") return await GET(req, res);
|
if (req.method === "GET") return await GET(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) {
|
||||||
const { ids, startDate, endDate } = req.query as {
|
const {ids, startDate, endDate} = req.query as {
|
||||||
ids: string;
|
ids: string;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const startDateParsed = startDate ? new Date(startDate) : undefined;
|
const startDateParsed = startDate ? new Date(startDate) : undefined;
|
||||||
const endDateParsed = endDate ? new Date(endDate) : undefined;
|
const endDateParsed = endDate ? new Date(endDate) : undefined;
|
||||||
try {
|
try {
|
||||||
const idsList = ids.split(",");
|
const idsList = ids.split(",");
|
||||||
|
|
||||||
const assigners = await Promise.all(
|
const assignments = await getAssignmentsForCorporates(idsList, startDateParsed, endDateParsed);
|
||||||
idsList.map(async (id) => {
|
res.status(200).json(assignments);
|
||||||
const assigners = await getAllAssignersByCorporate(id);
|
} catch (err: any) {
|
||||||
return {
|
res.status(500).json({error: err.message});
|
||||||
corporateId: id,
|
}
|
||||||
assigners,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const assignments = await Promise.all(assigners.map(async (data) => {
|
|
||||||
try {
|
|
||||||
const assigners = [...new Set([...data.assigners, data.corporateId])];
|
|
||||||
const assignments = await getAssignmentsByAssigners(
|
|
||||||
assigners,
|
|
||||||
startDateParsed,
|
|
||||||
endDateParsed
|
|
||||||
);
|
|
||||||
return assignments.map((assignment) => ({
|
|
||||||
...assignment,
|
|
||||||
corporateId: data.corporateId,
|
|
||||||
}));
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.log(assignments);
|
|
||||||
|
|
||||||
// const assignments = await getAssignmentsByAssigners(assignmentList, startDateParsed, endDateParsed);
|
|
||||||
res.status(200).json(assignments.flat());
|
|
||||||
} catch (err: any) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {capitalize, flatten, uniqBy} from "lodash";
|
|||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {sendEmail} from "@/email";
|
import {sendEmail} from "@/email";
|
||||||
import { release } from "os";
|
import {release} from "os";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
|
|
||||||
@@ -57,6 +57,7 @@ const generateExams = async (
|
|||||||
generateMultiple: Boolean,
|
generateMultiple: Boolean,
|
||||||
selectedModules: Module[],
|
selectedModules: Module[],
|
||||||
assignees: string[],
|
assignees: string[],
|
||||||
|
userId: string,
|
||||||
variant?: Variant,
|
variant?: Variant,
|
||||||
instructorGender?: InstructorGender,
|
instructorGender?: InstructorGender,
|
||||||
): Promise<ExamWithUser[]> => {
|
): Promise<ExamWithUser[]> => {
|
||||||
@@ -87,7 +88,7 @@ const generateExams = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedModulePromises = selectedModules.map(async (module: Module) => {
|
const selectedModulePromises = selectedModules.map(async (module: Module) => {
|
||||||
const exams: Exam[] = await getExams(db, module, "false", undefined, variant, instructorGender);
|
const exams: Exam[] = await getExams(db, module, "false", userId, variant, instructorGender);
|
||||||
const exam = exams[getRandomIndex(exams)];
|
const exam = exams[getRandomIndex(exams)];
|
||||||
|
|
||||||
if (exam) {
|
if (exam) {
|
||||||
@@ -122,11 +123,12 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
endDate: string;
|
endDate: string;
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
instructorGender?: InstructorGender;
|
instructorGender?: InstructorGender;
|
||||||
|
released: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const exams: ExamWithUser[] = !!examIDs
|
const exams: ExamWithUser[] = !!examIDs
|
||||||
? examIDs.flatMap((e) => assignees.map((a) => ({...e, assignee: a})))
|
? examIDs.flatMap((e) => assignees.map((a) => ({...e, assignee: a})))
|
||||||
: await generateExams(generateMultiple, selectedModules, assignees, variant, instructorGender);
|
: await generateExams(generateMultiple, selectedModules, assignees, req.session.user!.id, variant, instructorGender);
|
||||||
|
|
||||||
if (exams.length === 0) {
|
if (exams.length === 0) {
|
||||||
res.status(400).json({ok: false, error: "No exams found for the selected modules"});
|
res.status(400).json({ok: false, error: "No exams found for the selected modules"});
|
||||||
@@ -139,7 +141,6 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
results: [],
|
results: [],
|
||||||
exams,
|
exams,
|
||||||
instructorGender,
|
instructorGender,
|
||||||
released: false,
|
|
||||||
...body,
|
...body,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
243
src/pages/api/assignments/statistical/excel.ts
Normal file
243
src/pages/api/assignments/statistical/excel.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { app, storage } from "@/firebase";
|
||||||
|
import { getFirestore } from "firebase/firestore";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
|
||||||
|
import { AssignmentWithCorporateId } from "@/interfaces/results";
|
||||||
|
import moment from "moment-timezone";
|
||||||
|
import ExcelJS from "exceljs";
|
||||||
|
import { getSpecificUsers } from "@/utils/users.be";
|
||||||
|
import { checkAccess } from "@/utils/permissions";
|
||||||
|
import { getAssignmentsForCorporates } from "@/utils/assignments.be";
|
||||||
|
import { search } from "@/utils/search";
|
||||||
|
import { getGradingSystem } from "@/utils/grading.be";
|
||||||
|
import { Exam } from "@/interfaces/exam";
|
||||||
|
import { User } from "@/interfaces/user";
|
||||||
|
import { calculateBandScore, getGradingLabel } from "@/utils/score";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
interface TableData {
|
||||||
|
user: string;
|
||||||
|
email: string;
|
||||||
|
correct: number;
|
||||||
|
corporate: string;
|
||||||
|
submitted: boolean;
|
||||||
|
date: moment.Moment;
|
||||||
|
assignment: string;
|
||||||
|
corporateId: string;
|
||||||
|
score: number;
|
||||||
|
level: string;
|
||||||
|
part1?: string;
|
||||||
|
part2?: string;
|
||||||
|
part3?: string;
|
||||||
|
part4?: string;
|
||||||
|
part5?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
// if (req.method === "GET") return get(req, res);
|
||||||
|
if (req.method === "POST") return await post(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchFilters = [["email"], ["user"], ["userId"]];
|
||||||
|
|
||||||
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
// verify if it's a logged user that is trying to export
|
||||||
|
if (req.session.user) {
|
||||||
|
if (
|
||||||
|
!checkAccess(req.session.user, ["mastercorporate", "developer", "admin"])
|
||||||
|
) {
|
||||||
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
const { ids, startDate, endDate, searchText } = req.body as {
|
||||||
|
ids: string[];
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
searchText: string;
|
||||||
|
};
|
||||||
|
const startDateParsed = startDate ? new Date(startDate) : undefined;
|
||||||
|
const endDateParsed = endDate ? new Date(endDate) : undefined;
|
||||||
|
const assignments = await getAssignmentsForCorporates(
|
||||||
|
ids,
|
||||||
|
startDateParsed,
|
||||||
|
endDateParsed
|
||||||
|
);
|
||||||
|
|
||||||
|
const assignmentUsers = [
|
||||||
|
...new Set(assignments.flatMap((a) => a.assignees)),
|
||||||
|
];
|
||||||
|
const assigners = [...new Set(assignments.map((a) => a.assigner))];
|
||||||
|
const users = await getSpecificUsers(assignmentUsers);
|
||||||
|
const assignerUsers = await getSpecificUsers(assigners);
|
||||||
|
|
||||||
|
const assignerUsersGradingSystems = await Promise.all(
|
||||||
|
assignerUsers.map(async (user: User) => {
|
||||||
|
const data = await getGradingSystem(user);
|
||||||
|
// in this context I need to override as I'll have to match to the assigner
|
||||||
|
return { ...data, user: user.id };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const getGradingSystemHelper = (
|
||||||
|
exams: {id: string; module: Module; assignee: string}[],
|
||||||
|
assigner: string,
|
||||||
|
user: User,
|
||||||
|
correct: number,
|
||||||
|
total: number
|
||||||
|
) => {
|
||||||
|
if (exams.some((e) => e.module === "level")) {
|
||||||
|
const gradingSystem = assignerUsersGradingSystems.find(
|
||||||
|
(gs) => gs.user === assigner
|
||||||
|
);
|
||||||
|
if (gradingSystem) {
|
||||||
|
const bandScore = calculateBandScore(
|
||||||
|
correct,
|
||||||
|
total,
|
||||||
|
"level",
|
||||||
|
user.focus
|
||||||
|
);
|
||||||
|
return { label: getGradingLabel(bandScore, gradingSystem?.steps || []), score: bandScore };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { score: -1, label: "N/A" };
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableResults = assignments.reduce(
|
||||||
|
(accmA: TableData[], a: AssignmentWithCorporateId) => {
|
||||||
|
const userResults = a.assignees.map((assignee) => {
|
||||||
|
const userStats =
|
||||||
|
a.results.find((r) => r.user === assignee)?.stats || [];
|
||||||
|
const userData = users.find((u) => u.id === assignee);
|
||||||
|
const corporateUser = users.find((u) => u.id === a.assigner);
|
||||||
|
const correct = userStats.reduce((n, e) => n + e.score.correct, 0);
|
||||||
|
const total = userStats.reduce((n, e) => n + e.score.total, 0);
|
||||||
|
const { label: level, score } = getGradingSystemHelper(
|
||||||
|
a.exams,
|
||||||
|
a.assigner,
|
||||||
|
userData!,
|
||||||
|
correct,
|
||||||
|
total
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
console.log("Level", level);
|
||||||
|
const commonData = {
|
||||||
|
user: userData?.name || "",
|
||||||
|
email: userData?.email || "",
|
||||||
|
userId: assignee,
|
||||||
|
corporateId: a.corporateId,
|
||||||
|
corporate: corporateUser?.name || "",
|
||||||
|
assignment: a.name,
|
||||||
|
level,
|
||||||
|
score,
|
||||||
|
};
|
||||||
|
if (userStats.length === 0) {
|
||||||
|
return {
|
||||||
|
...commonData,
|
||||||
|
correct: 0,
|
||||||
|
submitted: false,
|
||||||
|
// date: moment(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const partsData = userStats.every((e) => e.module === "level") ? userStats.reduce((acc, e, index) => {
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
[`part${index}`]: `${e.score.correct}/${e.score.total}`
|
||||||
|
}
|
||||||
|
}, {}) : {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...commonData,
|
||||||
|
correct,
|
||||||
|
submitted: true,
|
||||||
|
date: moment.max(userStats.map((e) => moment(e.date))),
|
||||||
|
...partsData,
|
||||||
|
};
|
||||||
|
}) as TableData[];
|
||||||
|
|
||||||
|
return [...accmA, ...userResults];
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
).sort((a,b) => b.score - a.score);
|
||||||
|
|
||||||
|
// Create a new workbook and add a worksheet
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const worksheet = workbook.addWorksheet("Master Statistical");
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
{
|
||||||
|
label: "User",
|
||||||
|
value: (entry: TableData) => entry.user,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Email",
|
||||||
|
value: (entry: TableData) => entry.email,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Corporate",
|
||||||
|
value: (entry: TableData) => entry.corporate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Assignment",
|
||||||
|
value: (entry: TableData) => entry.assignment,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Submitted",
|
||||||
|
value: (entry: TableData) => (entry.submitted ? "Yes" : "No"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Correct",
|
||||||
|
value: (entry: TableData) => entry.correct,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Date",
|
||||||
|
value: (entry: TableData) => entry.date?.format("YYYY/MM/DD") || "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Level",
|
||||||
|
value: (entry: TableData) => entry.level,
|
||||||
|
},
|
||||||
|
...new Array(5).fill(0).map((_, index) => ({
|
||||||
|
label: `Part ${index + 1}`,
|
||||||
|
value: (entry: TableData) => {
|
||||||
|
const key = `part${index}` as keyof TableData;
|
||||||
|
return entry[key] || "";
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredSearch = searchText
|
||||||
|
? search(searchText, searchFilters, tableResults)
|
||||||
|
: tableResults;
|
||||||
|
|
||||||
|
worksheet.addRow(headers.map((h) => h.label));
|
||||||
|
(filteredSearch as TableData[]).forEach((entry) => {
|
||||||
|
worksheet.addRow(headers.map((h) => h.value(entry)));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert workbook to Buffer (Node.js) or Blob (Browser)
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
|
||||||
|
// generate the file ref for storage
|
||||||
|
const fileName = `${Date.now().toString()}.xlsx`;
|
||||||
|
const refName = `statistical/${fileName}`;
|
||||||
|
const fileRef = ref(storage, refName);
|
||||||
|
// upload the pdf to storage
|
||||||
|
const snapshot = await uploadBytes(fileRef, buffer, {
|
||||||
|
contentType:
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = await getDownloadURL(fileRef);
|
||||||
|
res.status(200).end(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
61
src/pages/api/batch_users.ts
Normal file
61
src/pages/api/batch_users.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
import { FirebaseScrypt } from 'firebase-scrypt';
|
||||||
|
import { firebaseAuthScryptParams } from "@/firebase";
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
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 scrypt = new FirebaseScrypt(firebaseAuthScryptParams)
|
||||||
|
|
||||||
|
const users = req.body.users as {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
passport_id: string;
|
||||||
|
groupName?: string;
|
||||||
|
corporate?: string;
|
||||||
|
studentID?: string;
|
||||||
|
expiryDate?: string;
|
||||||
|
demographicInformation: {
|
||||||
|
country?: string;
|
||||||
|
passport_id?: string;
|
||||||
|
phone: string;
|
||||||
|
};
|
||||||
|
passwordHash: string | undefined;
|
||||||
|
passwordSalt: string | undefined;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
const usersWithPasswordHashes = await Promise.all(users.map(async (user) => {
|
||||||
|
const currentUser = { ...user };
|
||||||
|
const salt = crypto.randomBytes(16).toString('base64');
|
||||||
|
const hash = await scrypt.hash(user.passport_id, salt);
|
||||||
|
|
||||||
|
currentUser.email = currentUser.email.toLowerCase();
|
||||||
|
currentUser.passwordHash = hash;
|
||||||
|
currentUser.passwordSalt = salt;
|
||||||
|
return currentUser;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/batch_users`, { makerID: maker.id, users: usersWithPasswordHashes }, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(backendRequest.status).json(backendRequest.data)
|
||||||
|
}
|
||||||
@@ -1,89 +1,76 @@
|
|||||||
// 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, runTransaction, collection, query, where, getDocs} from "firebase/firestore";
|
||||||
getFirestore,
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
setDoc,
|
import {sessionOptions} from "@/lib/session";
|
||||||
doc,
|
import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
|
||||||
runTransaction,
|
import {getExams} from "@/utils/exams.be";
|
||||||
collection,
|
import {Module} from "@/interfaces";
|
||||||
query,
|
import {getUserCorporate} from "@/utils/groups.be";
|
||||||
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(
|
const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id, variant, instructorGender);
|
||||||
db,
|
res.status(200).json(exams);
|
||||||
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") {
|
const {module} = req.query as {module: string};
|
||||||
res.status(403).json({ ok: false });
|
const corporate = await getUserCorporate(req.session.user.id);
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { module } = req.query as { module: string };
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const exam = {
|
const exam = {
|
||||||
...req.body,
|
...req.body,
|
||||||
module: module,
|
module: module,
|
||||||
createdBy: req.session.user.id,
|
owners: [
|
||||||
createdAt: new Date().toISOString(),
|
...(["mastercorporate", "corporate"].includes(req.session.user.type) ? [req.session.user.id] : []),
|
||||||
};
|
...(!!corporate ? [corporate.id] : []),
|
||||||
await runTransaction(db, async (transaction) => {
|
],
|
||||||
const docRef = doc(db, module, req.body.id);
|
createdBy: req.session.user.id,
|
||||||
const docSnap = await transaction.get(docRef);
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
if (docSnap.exists()) {
|
await runTransaction(db, async (transaction) => {
|
||||||
throw new Error("Name already exists");
|
const docRef = doc(db, module, req.body.id);
|
||||||
}
|
const docSnap = await transaction.get(docRef);
|
||||||
|
|
||||||
const newDocRef = doc(db, module, req.body.id);
|
if (docSnap.exists()) {
|
||||||
transaction.set(newDocRef, exam);
|
throw new Error("Name already exists");
|
||||||
});
|
}
|
||||||
res.status(200).json(exam);
|
|
||||||
} catch (error) {
|
const newDocRef = doc(db, module, req.body.id);
|
||||||
console.error("Transaction failed: ", error);
|
transaction.set(newDocRef, exam);
|
||||||
res.status(500).json({ ok: false, error: (error as any).message });
|
});
|
||||||
}
|
res.status(200).json(exam);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Transaction failed: ", error);
|
||||||
|
res.status(500).json({ok: false, error: (error as any).message});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const moduleExamsPromises = [...MODULE_ARRAY, "level"].map(async (module) => {
|
const moduleExamsPromises = MODULE_ARRAY.map(async (module) => {
|
||||||
const moduleRef = collection(db, module);
|
const moduleRef = collection(db, module);
|
||||||
|
|
||||||
const q = query(moduleRef, where("isDiagnostic", "==", false));
|
const q = query(moduleRef, where("isDiagnostic", "==", false));
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {Grading} from "@/interfaces";
|
|||||||
import {getGroupsForUser} from "@/utils/groups.be";
|
import {getGroupsForUser} from "@/utils/groups.be";
|
||||||
import {uniq} from "lodash";
|
import {uniq} from "lodash";
|
||||||
import {getUser} from "@/utils/users.be";
|
import {getUser} from "@/utils/users.be";
|
||||||
|
import { getGradingSystem } from "@/utils/grading.be";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
|
|
||||||
@@ -31,19 +32,8 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = await getDoc(doc(db, "grading", req.session.user.id));
|
const gradingSystem = await getGradingSystem(req.session.user);
|
||||||
if (snapshot.exists()) return res.status(200).json(snapshot.data());
|
return res.status(200).json(gradingSystem);
|
||||||
|
|
||||||
if (req.session.user.type !== "teacher" && req.session.user.type !== "student")
|
|
||||||
return res.status(200).json({steps: CEFR_STEPS, user: req.session.user.id});
|
|
||||||
|
|
||||||
const corporate = await getUserCorporate(req.session.user.id);
|
|
||||||
if (!corporate) return res.status(200).json(CEFR_STEPS);
|
|
||||||
|
|
||||||
const corporateSnapshot = await getDoc(doc(db, "grading", corporate.id));
|
|
||||||
if (corporateSnapshot.exists()) return res.status(200).json(snapshot.data());
|
|
||||||
|
|
||||||
return res.status(200).json({steps: CEFR_STEPS, user: req.session.user.id});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {sessionOptions} from "@/lib/session";
|
|||||||
import {Group} from "@/interfaces/user";
|
import {Group} from "@/interfaces/user";
|
||||||
import {v4} from "uuid";
|
import {v4} from "uuid";
|
||||||
import {updateExpiryDateOnGroup, getGroupsForUser} from "@/utils/groups.be";
|
import {updateExpiryDateOnGroup, getGroupsForUser} from "@/utils/groups.be";
|
||||||
import {uniqBy} from "lodash";
|
import {uniq, uniqBy} from "lodash";
|
||||||
|
import {getUser} from "@/utils/users.be";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
|
|
||||||
@@ -29,29 +30,10 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
participant: string;
|
participant: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (req.session?.user?.type === "mastercorporate") {
|
const adminGroups = await getGroupsForUser(admin, participant);
|
||||||
try {
|
const participants = uniq(adminGroups.flatMap((g) => g.participants));
|
||||||
const masterCorporateGroups = await getGroupsForUser(admin, participant);
|
const groups = await Promise.all(participants.map(async (c) => await getGroupsForUser(c, participant)));
|
||||||
const corporatesFromMaster = masterCorporateGroups.filter((g) => g.name.trim() === "Corporate").flatMap((g) => g.participants);
|
return res.status(200).json([...adminGroups, ...uniqBy(groups.flat(), "id")]);
|
||||||
|
|
||||||
if (corporatesFromMaster.length === 0) return res.status(200).json(masterCorporateGroups);
|
|
||||||
|
|
||||||
const groups = await Promise.all(corporatesFromMaster.map((c) => getGroupsForUser(c, participant)));
|
|
||||||
return res.status(200).json([...masterCorporateGroups, ...uniqBy(groups.flat(), "id")]);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import {getFirestore, setDoc, doc, query, collection, where, getDocs, getDoc, de
|
|||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {v4} from "uuid";
|
import {v4} from "uuid";
|
||||||
import {CorporateUser, Group} from "@/interfaces/user";
|
import {CorporateUser, Group, Type} from "@/interfaces/user";
|
||||||
import {createUserWithEmailAndPassword, getAuth} from "firebase/auth";
|
import {createUserWithEmailAndPassword, getAuth} from "firebase/auth";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
|
import {getUserCorporate, getUserGroups} from "@/utils/groups.be";
|
||||||
|
import {uniq} from "lodash";
|
||||||
|
import {getUser} from "@/utils/users.be";
|
||||||
|
|
||||||
const DEFAULT_DESIRED_LEVELS = {
|
const DEFAULT_DESIRED_LEVELS = {
|
||||||
reading: 9,
|
reading: 9,
|
||||||
@@ -27,6 +30,13 @@ const db = getFirestore(app);
|
|||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
const getUsersOfType = async (admin: string, type: Type) => {
|
||||||
|
const groups = await getUserGroups(admin);
|
||||||
|
const users = await Promise.all(uniq(groups.flatMap((x) => x.participants)).map(getUser));
|
||||||
|
|
||||||
|
return users.filter((x) => x.type === type).map((x) => x.id);
|
||||||
|
};
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method === "POST") return post(req, res);
|
if (req.method === "POST") return post(req, res);
|
||||||
|
|
||||||
@@ -38,19 +48,20 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
if (!maker) {
|
if (!maker) {
|
||||||
return res.status(401).json({ok: false, reason: "You must be logged in to make user!"});
|
return res.status(401).json({ok: false, reason: "You must be logged in to make user!"});
|
||||||
}
|
}
|
||||||
const {email, passport_id, password, type, groupName, groupID, expiryDate, corporate} = req.body as {
|
|
||||||
|
const corporateCorporate = await getUserCorporate(maker.id);
|
||||||
|
|
||||||
|
const {email, passport_id, password, type, groupID, expiryDate, corporate} = req.body as {
|
||||||
email: string;
|
email: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
passport_id: string;
|
passport_id: string;
|
||||||
type: string;
|
type: string;
|
||||||
groupName?: string;
|
|
||||||
groupID?: string;
|
groupID?: string;
|
||||||
corporate?: string;
|
corporate?: string;
|
||||||
expiryDate: null | Date;
|
expiryDate: null | Date;
|
||||||
};
|
};
|
||||||
// cleaning data
|
// cleaning data
|
||||||
delete req.body.passport_id;
|
delete req.body.passport_id;
|
||||||
delete req.body.groupName;
|
|
||||||
delete req.body.groupID;
|
delete req.body.groupID;
|
||||||
delete req.body.expiryDate;
|
delete req.body.expiryDate;
|
||||||
delete req.body.password;
|
delete req.body.password;
|
||||||
@@ -60,6 +71,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
.then(async (userCredentials) => {
|
.then(async (userCredentials) => {
|
||||||
const userId = userCredentials.user.uid;
|
const userId = userCredentials.user.uid;
|
||||||
|
|
||||||
|
const profilePicture = !corporateCorporate ? "/defaultAvatar.png" : corporateCorporate.profilePicture;
|
||||||
|
|
||||||
const user = {
|
const user = {
|
||||||
...req.body,
|
...req.body,
|
||||||
bio: "",
|
bio: "",
|
||||||
@@ -67,12 +80,22 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
focus: "academic",
|
focus: "academic",
|
||||||
status: "active",
|
status: "active",
|
||||||
desiredLevels: DEFAULT_DESIRED_LEVELS,
|
desiredLevels: DEFAULT_DESIRED_LEVELS,
|
||||||
profilePicture: "/defaultAvatar.png",
|
profilePicture,
|
||||||
levels: DEFAULT_LEVELS,
|
levels: DEFAULT_LEVELS,
|
||||||
isFirstLogin: false,
|
isFirstLogin: false,
|
||||||
isVerified: true,
|
isVerified: true,
|
||||||
registrationDate: new Date(),
|
registrationDate: new Date(),
|
||||||
subscriptionExpirationDate: expiryDate || null,
|
subscriptionExpirationDate: expiryDate || null,
|
||||||
|
...((maker.type === "corporate" || maker.type === "mastercorporate") && type === "corporate"
|
||||||
|
? {
|
||||||
|
corporateInformation: {
|
||||||
|
companyInformation: {
|
||||||
|
name: maker.corporateInformation?.companyInformation?.name || "N/A",
|
||||||
|
userAmount: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const uid = new ShortUniqueId();
|
const uid = new ShortUniqueId();
|
||||||
@@ -88,15 +111,18 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
userId,
|
userId,
|
||||||
email: email.toLowerCase(),
|
email: email.toLowerCase(),
|
||||||
name: req.body.name,
|
name: req.body.name,
|
||||||
passport_id,
|
...(!!passport_id ? {passport_id} : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (type === "corporate") {
|
if (type === "corporate") {
|
||||||
|
const students = maker.type === "corporate" ? await getUsersOfType(maker.id, "student") : [];
|
||||||
|
const teachers = maker.type === "corporate" ? await getUsersOfType(maker.id, "teacher") : [];
|
||||||
|
|
||||||
const defaultTeachersGroup: Group = {
|
const defaultTeachersGroup: Group = {
|
||||||
admin: userId,
|
admin: userId,
|
||||||
id: v4(),
|
id: v4(),
|
||||||
name: "Teachers",
|
name: "Teachers",
|
||||||
participants: [],
|
participants: teachers,
|
||||||
disableEditing: true,
|
disableEditing: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,29 +130,20 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
admin: userId,
|
admin: userId,
|
||||||
id: v4(),
|
id: v4(),
|
||||||
name: "Students",
|
name: "Students",
|
||||||
participants: [],
|
participants: students,
|
||||||
disableEditing: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultCorporateGroup: Group = {
|
|
||||||
admin: userId,
|
|
||||||
id: v4(),
|
|
||||||
name: "Corporate",
|
|
||||||
participants: [],
|
|
||||||
disableEditing: true,
|
disableEditing: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
await setDoc(doc(db, "groups", defaultTeachersGroup.id), defaultTeachersGroup);
|
await setDoc(doc(db, "groups", defaultTeachersGroup.id), defaultTeachersGroup);
|
||||||
await setDoc(doc(db, "groups", defaultStudentsGroup.id), defaultStudentsGroup);
|
await setDoc(doc(db, "groups", defaultStudentsGroup.id), defaultStudentsGroup);
|
||||||
await setDoc(doc(db, "groups", defaultCorporateGroup.id), defaultCorporateGroup);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!!corporate) {
|
if (!!corporate) {
|
||||||
const corporateQ = query(collection(db, "users"), where("email", "==", corporate));
|
const corporateQ = query(collection(db, "users"), where("email", "==", corporate.trim().toLowerCase()));
|
||||||
const corporateSnapshot = await getDocs(corporateQ);
|
const corporateSnapshot = await getDocs(corporateQ);
|
||||||
|
|
||||||
if (!corporateSnapshot.empty) {
|
if (!corporateSnapshot.empty) {
|
||||||
const corporateUser = corporateSnapshot.docs[0].data() as CorporateUser;
|
const corporateUser = {...corporateSnapshot.docs[0].data(), id: corporateSnapshot.docs[0].id} as CorporateUser;
|
||||||
await setDoc(doc(db, "codes", code), {creator: corporateUser.id}, {merge: true});
|
await setDoc(doc(db, "codes", code), {creator: corporateUser.id}, {merge: true});
|
||||||
|
|
||||||
const q = query(
|
const q = query(
|
||||||
@@ -146,33 +163,76 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
participants: [...participants, userId],
|
participants: [...participants, userId],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
const defaultGroup: Group = {
|
||||||
|
admin: corporateUser.id,
|
||||||
|
id: v4(),
|
||||||
|
name: type === "student" ? "Students" : "Teachers",
|
||||||
|
participants: [userId],
|
||||||
|
disableEditing: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
await setDoc(doc(db, "groups", defaultGroup.id), defaultGroup);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof groupName === "string" && groupName.trim().length > 0) {
|
if (maker.type === "corporate") {
|
||||||
const q = query(collection(db, "groups"), where("admin", "==", maker.id), where("name", "==", groupName.trim()), limit(1));
|
await setDoc(doc(db, "codes", code), {creator: maker.id}, {merge: true});
|
||||||
|
|
||||||
|
const q = query(
|
||||||
|
collection(db, "groups"),
|
||||||
|
where("admin", "==", maker.id),
|
||||||
|
where("name", "==", type === "student" ? "Students" : "Teachers"),
|
||||||
|
limit(1),
|
||||||
|
);
|
||||||
const snapshot = await getDocs(q);
|
const snapshot = await getDocs(q);
|
||||||
|
|
||||||
if (snapshot.empty) {
|
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 doc = snapshot.docs[0];
|
||||||
const participants: string[] = doc.get("participants");
|
const participants: string[] = doc.get("participants");
|
||||||
|
|
||||||
if (!participants.includes(userId)) {
|
if (!participants.includes(userId)) {
|
||||||
updateDoc(doc.ref, {
|
await updateDoc(doc.ref, {
|
||||||
participants: [...participants, userId],
|
participants: [...participants, userId],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
const defaultGroup: Group = {
|
||||||
|
admin: maker.id,
|
||||||
|
id: v4(),
|
||||||
|
name: type === "student" ? "Students" : "Teachers",
|
||||||
|
participants: [userId],
|
||||||
|
disableEditing: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
await setDoc(doc(db, "groups", defaultGroup.id), defaultGroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!!corporateCorporate && corporateCorporate.type === "mastercorporate" && type === "corporate") {
|
||||||
|
const q = query(collection(db, "groups"), where("admin", "==", corporateCorporate.id), where("name", "==", "corporate"), limit(1));
|
||||||
|
const snapshot = await getDocs(q);
|
||||||
|
|
||||||
|
if (!snapshot.empty) {
|
||||||
|
const doc = snapshot.docs[0];
|
||||||
|
const participants: string[] = doc.get("participants");
|
||||||
|
|
||||||
|
if (!participants.includes(userId)) {
|
||||||
|
await updateDoc(doc.ref, {
|
||||||
|
participants: [...participants, userId],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const defaultGroup: Group = {
|
||||||
|
admin: corporateCorporate.id,
|
||||||
|
id: v4(),
|
||||||
|
name: "Corporate",
|
||||||
|
participants: [userId],
|
||||||
|
disableEditing: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
await setDoc(doc(db, "groups", defaultGroup.id), defaultGroup);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {app} from "@/firebase";
|
|||||||
import {getFirestore, collection, getDocs, query, where, doc, setDoc, addDoc, getDoc} from "firebase/firestore";
|
import {getFirestore, collection, getDocs, query, where, doc, setDoc, addDoc, 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 {Session} from "@/hooks/useSessions";
|
||||||
|
import moment from "moment";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
|
|
||||||
@@ -24,12 +26,17 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const q = user ? query(collection(db, "sessions"), where("user", "==", user)) : collection(db, "sessions");
|
const q = user ? query(collection(db, "sessions"), where("user", "==", user)) : collection(db, "sessions");
|
||||||
const snapshot = await getDocs(q);
|
const snapshot = await getDocs(q);
|
||||||
|
const sessions = snapshot.docs.map((doc) => ({
|
||||||
|
id: doc.id,
|
||||||
|
...doc.data(),
|
||||||
|
})) as Session[];
|
||||||
|
|
||||||
res.status(200).json(
|
res.status(200).json(
|
||||||
snapshot.docs.map((doc) => ({
|
sessions.filter((x) => {
|
||||||
id: doc.id,
|
if (!x.assignment) return true;
|
||||||
...doc.data(),
|
if (x.assignment.results.filter((y) => y.user === user).length > 0) return false;
|
||||||
})),
|
return !moment().isAfter(moment(x.assignment.endDate));
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const targetUser = {...docTargetUser.data(), id: docTargetUser.id} as User;
|
const targetUser = {...docTargetUser.data(), id: docTargetUser.id} as User;
|
||||||
|
|
||||||
if (user.type === "corporate" && (targetUser.type === "student" || targetUser.type === "teacher")) {
|
if (user.type === "corporate" && (targetUser.type === "student" || targetUser.type === "teacher")) {
|
||||||
res.json({ok: true});
|
|
||||||
|
|
||||||
const userParticipantGroup = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", id)));
|
const userParticipantGroup = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", id)));
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
...userParticipantGroup.docs
|
...userParticipantGroup.docs
|
||||||
@@ -66,14 +64,6 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const permission = PERMISSIONS.deleteUser[targetUser.type];
|
|
||||||
if (!permission.list.includes(user.type)) {
|
|
||||||
res.status(403).json({ok: false});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ok: true});
|
|
||||||
|
|
||||||
await auth.deleteUser(id);
|
await auth.deleteUser(id);
|
||||||
await deleteDoc(doc(db, "users", id));
|
await deleteDoc(doc(db, "users", id));
|
||||||
const userCodeDocs = await getDocs(query(collection(db, "codes"), where("userId", "==", id)));
|
const userCodeDocs = await getDocs(query(collection(db, "codes"), where("userId", "==", id)));
|
||||||
@@ -96,6 +86,8 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
res.json({ok: true});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
// 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 {getFirestore, collection, getDocs} 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 {getLinkedUsers} from "@/utils/users.be";
|
||||||
const db = getFirestore(app);
|
import {Type} from "@/interfaces/user";
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
@@ -15,12 +13,16 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = await getDocs(collection(db, "users"));
|
const {size, type, latestID, firstID} = req.query as {size?: string; type?: Type; latestID?: string; firstID?: string};
|
||||||
|
|
||||||
res.status(200).json(
|
const {users, total} = await getLinkedUsers(
|
||||||
snapshot.docs.map((doc) => ({
|
req.session.user?.id,
|
||||||
id: doc.id,
|
req.session.user?.type,
|
||||||
...doc.data(),
|
type,
|
||||||
})),
|
firstID,
|
||||||
|
latestID,
|
||||||
|
size !== undefined ? parseInt(size) : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
res.status(200).json({users, total});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ import MasterCorporateDashboard from "@/dashboards/MasterCorporate";
|
|||||||
import PaymentDue from "./(status)/PaymentDue";
|
import PaymentDue from "./(status)/PaymentDue";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {PayPalScriptProvider} from "@paypal/react-paypal-js";
|
import {PayPalScriptProvider} from "@paypal/react-paypal-js";
|
||||||
import {CorporateUser, MasterCorporateUser, Type, userTypes} from "@/interfaces/user";
|
import {CorporateUser, MasterCorporateUser, Type, User, userTypes} from "@/interfaces/user";
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
||||||
import {getUserCorporate} from "@/utils/groups.be";
|
import {getUserCorporate} from "@/utils/groups.be";
|
||||||
|
import {getUsers} from "@/utils/users.be";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
@@ -62,7 +63,7 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
|||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: any;
|
user: User;
|
||||||
envVariables: {[key: string]: string};
|
envVariables: {[key: string]: string};
|
||||||
linkedCorporate?: CorporateUser | MasterCorporateUser;
|
linkedCorporate?: CorporateUser | MasterCorporateUser;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,85 +1,82 @@
|
|||||||
import Layout from "@/components/High/Layout";
|
import Layout from "@/components/High/Layout";
|
||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import { sessionOptions } from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import useFilterStore from "@/stores/listFilterStore";
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
import { withIronSessionSsr } from "iron-session/next";
|
import {withIronSessionSsr} from "iron-session/next";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useRouter } from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import { useEffect } from "react";
|
import {useEffect} from "react";
|
||||||
import { BsArrowLeft } from "react-icons/bs";
|
import {BsArrowLeft} from "react-icons/bs";
|
||||||
import { ToastContainer } from "react-toastify";
|
import {ToastContainer} from "react-toastify";
|
||||||
import UserList from "../(admin)/Lists/UserList";
|
import UserList from "../(admin)/Lists/UserList";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
|
|
||||||
const envVariables: { [key: string]: string } = {};
|
const envVariables: {[key: string]: string} = {};
|
||||||
Object.keys(process.env)
|
Object.keys(process.env)
|
||||||
.filter((x) => x.startsWith("NEXT_PUBLIC"))
|
.filter((x) => x.startsWith("NEXT_PUBLIC"))
|
||||||
.forEach((x: string) => {
|
.forEach((x: string) => {
|
||||||
envVariables[x] = process.env[x]!;
|
envVariables[x] = process.env[x]!;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user || !user.isVerified) {
|
if (!user || !user.isVerified) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
destination: "/login",
|
destination: "/login",
|
||||||
permanent: false,
|
permanent: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: { user: req.session.user, envVariables },
|
props: {user: req.session.user, envVariables},
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
export default function UsersListPage() {
|
export default function UsersListPage() {
|
||||||
const { user } = useUser();
|
const {user} = useUser();
|
||||||
const { users } = useUsers();
|
|
||||||
const [filters, clearFilters] = useFilterStore((state) => [
|
|
||||||
state.userFilters,
|
|
||||||
state.clearUserFilters,
|
|
||||||
]);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return (
|
const [filters, clearFilters] = useFilterStore((state) => [state.userFilters, state.clearUserFilters]);
|
||||||
<>
|
|
||||||
<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 && (
|
const router = useRouter();
|
||||||
<Layout user={user}>
|
|
||||||
<UserList
|
return (
|
||||||
user={user}
|
<>
|
||||||
filters={filters.map((f) => f.filter)}
|
<Head>
|
||||||
renderHeader={(total) => (
|
<title>EnCoach</title>
|
||||||
<div className="flex flex-col gap-4">
|
<meta
|
||||||
<div
|
name="description"
|
||||||
onClick={() => {
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||||
clearFilters();
|
/>
|
||||||
router.back();
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
}}
|
<link rel="icon" href="/favicon.ico" />
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
|
</Head>
|
||||||
>
|
<ToastContainer />
|
||||||
<BsArrowLeft className="text-xl" />
|
|
||||||
<span>Back</span>
|
{user && (
|
||||||
</div>
|
<Layout user={user}>
|
||||||
<h2 className="text-2xl font-semibold">Users ({total})</h2>
|
<UserList
|
||||||
</div>
|
user={user}
|
||||||
)}
|
filters={filters.map((f) => f.filter)}
|
||||||
/>
|
renderHeader={(total) => (
|
||||||
</Layout>
|
<div className="flex flex-col gap-4">
|
||||||
)}
|
<div
|
||||||
</>
|
onClick={() => {
|
||||||
);
|
clearFilters();
|
||||||
|
router.back();
|
||||||
|
}}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Users ({total})</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,9 +97,7 @@ function UserProfile({user, mutateUser, linkedCorporate, groups, users}: Props)
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [profilePicture, setProfilePicture] = useState(user.profilePicture);
|
const [profilePicture, setProfilePicture] = useState(user.profilePicture);
|
||||||
|
|
||||||
const [desiredLevels, setDesiredLevels] = useState<{[key in Module]: number} | undefined>(
|
const [desiredLevels, setDesiredLevels] = useState(checkAccess(user, ["developer", "student"]) ? user.desiredLevels : undefined);
|
||||||
checkAccess(user, ["developer", "student"]) ? user.desiredLevels : undefined,
|
|
||||||
);
|
|
||||||
const [focus, setFocus] = useState<"academic" | "general">(user.focus);
|
const [focus, setFocus] = useState<"academic" | "general">(user.focus);
|
||||||
|
|
||||||
const [country, setCountry] = useState<string>(user.demographicInformation?.country || "");
|
const [country, setCountry] = useState<string>(user.demographicInformation?.country || "");
|
||||||
@@ -119,16 +117,17 @@ function UserProfile({user, mutateUser, linkedCorporate, groups, users}: Props)
|
|||||||
user.type === "student" || user.type === "developer" ? user.preferredTopics : undefined,
|
user.type === "student" || user.type === "developer" ? user.preferredTopics : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
|
const [position, setPosition] = useState<string | undefined>(
|
||||||
const [corporateInformation, setCorporateInformation] = useState(user.type === "corporate" ? user.corporateInformation : undefined);
|
user.type === "corporate" || user.type === "mastercorporate" ? user.demographicInformation?.position : undefined,
|
||||||
const [companyName, setCompanyName] = useState<string | undefined>(user.type === "agent" ? user.agentInformation?.companyName : undefined);
|
|
||||||
const [commercialRegistration, setCommercialRegistration] = useState<string | undefined>(
|
|
||||||
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
|
|
||||||
);
|
);
|
||||||
|
const [corporateInformation, setCorporateInformation] = useState(
|
||||||
|
user.type === "corporate" || user.type === "mastercorporate" ? user.corporateInformation : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [companyName] = useState<string | undefined>(user.type === "agent" ? user.agentInformation?.companyName : undefined);
|
||||||
|
const [commercialRegistration] = useState<string | undefined>(user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined);
|
||||||
const [arabName, setArabName] = useState<string | undefined>(user.type === "agent" ? user.agentInformation?.companyArabName : undefined);
|
const [arabName, setArabName] = useState<string | undefined>(user.type === "agent" ? user.agentInformation?.companyArabName : undefined);
|
||||||
|
|
||||||
const [timezone, setTimezone] = useState<string>(user.demographicInformation?.timezone || moment.tz.guess());
|
const [timezone, setTimezone] = useState<string>(user.demographicInformation?.timezone || moment.tz.guess());
|
||||||
|
|
||||||
const [isPreferredTopicsOpen, setIsPreferredTopicsOpen] = useState(false);
|
const [isPreferredTopicsOpen, setIsPreferredTopicsOpen] = useState(false);
|
||||||
|
|
||||||
const profilePictureInput = useRef(null);
|
const profilePictureInput = useRef(null);
|
||||||
@@ -288,7 +287,7 @@ function UserProfile({user, mutateUser, linkedCorporate, groups, users}: Props)
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
placeholder="Enter your company's name"
|
placeholder="Enter your company's name"
|
||||||
defaultValue={corporateInformation?.companyInformation.name}
|
defaultValue={corporateInformation?.companyInformation?.name}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -481,7 +480,7 @@ function UserProfile({user, mutateUser, linkedCorporate, groups, users}: Props)
|
|||||||
name="companyUsers"
|
name="companyUsers"
|
||||||
onChange={() => null}
|
onChange={() => null}
|
||||||
label="Number of users"
|
label="Number of users"
|
||||||
defaultValue={user.corporateInformation.companyInformation.userAmount}
|
defaultValue={user.corporateInformation?.companyInformation.userAmount}
|
||||||
disabled
|
disabled
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import useTrainingContentStore from "@/stores/trainingContentStore";
|
|||||||
import {Assignment} from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
import {getUsers} from "@/utils/users.be";
|
import {getUsers} from "@/utils/users.be";
|
||||||
import {getAssignments, getAssignmentsByAssigner} from "@/utils/assignments.be";
|
import {getAssignments, getAssignmentsByAssigner} from "@/utils/assignments.be";
|
||||||
|
import useGradingSystem from "@/hooks/useGrading";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
@@ -76,6 +77,7 @@ export default function History({user, users, assignments}: Props) {
|
|||||||
const [filter, setFilter] = useState<Filter>();
|
const [filter, setFilter] = useState<Filter>();
|
||||||
|
|
||||||
const {data: stats, isLoading: isStatsLoading} = useFilterRecordsByUser<Stat[]>(statsUserId || user?.id);
|
const {data: stats, isLoading: isStatsLoading} = useFilterRecordsByUser<Stat[]>(statsUserId || user?.id);
|
||||||
|
const {gradingSystem} = useGradingSystem();
|
||||||
|
|
||||||
const setExams = useExamStore((state) => state.setExams);
|
const setExams = useExamStore((state) => state.setExams);
|
||||||
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
||||||
@@ -185,6 +187,7 @@ export default function History({user, users, assignments}: Props) {
|
|||||||
setSelectedTrainingExams={setSelectedTrainingExams}
|
setSelectedTrainingExams={setSelectedTrainingExams}
|
||||||
maxTrainingExams={MAX_TRAINING_EXAMS}
|
maxTrainingExams={MAX_TRAINING_EXAMS}
|
||||||
setExams={setExams}
|
setExams={setExams}
|
||||||
|
gradingSystem={gradingSystem?.steps}
|
||||||
setShowSolutions={setShowSolutions}
|
setShowSolutions={setShowSolutions}
|
||||||
setUserSolutions={setUserSolutions}
|
setUserSolutions={setUserSolutions}
|
||||||
setSelectedModules={setSelectedModules}
|
setSelectedModules={setSelectedModules}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
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 {ToastContainer} from "react-toastify";
|
import {ToastContainer} from "react-toastify";
|
||||||
import Layout from "@/components/High/Layout";
|
import Layout from "@/components/High/Layout";
|
||||||
import CodeGenerator from "./(admin)/CodeGenerator";
|
import CodeGenerator from "./(admin)/CodeGenerator";
|
||||||
@@ -28,6 +27,7 @@ import {User} from "@/interfaces/user";
|
|||||||
import {getUserPermissions} from "@/utils/permissions.be";
|
import {getUserPermissions} from "@/utils/permissions.be";
|
||||||
import {Permission, PermissionType} from "@/interfaces/permissions";
|
import {Permission, PermissionType} from "@/interfaces/permissions";
|
||||||
import {getUsers} from "@/utils/users.be";
|
import {getUsers} from "@/utils/users.be";
|
||||||
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
@@ -50,21 +50,20 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const permissions = await getUserPermissions(user.id);
|
const permissions = await getUserPermissions(user.id);
|
||||||
const users = await getUsers();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {user, permissions, users},
|
props: {user, permissions},
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
users: User[];
|
|
||||||
permissions: PermissionType[];
|
permissions: PermissionType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Admin({user, users, permissions}: Props) {
|
export default function Admin({user, permissions}: Props) {
|
||||||
const {gradingSystem, mutate} = useGradingSystem();
|
const {gradingSystem, mutate} = useGradingSystem();
|
||||||
|
const {users} = useUsers();
|
||||||
|
|
||||||
const [modalOpen, setModalOpen] = useState<string>();
|
const [modalOpen, setModalOpen] = useState<string>();
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function getUserCompanyName(user: User, users: User[], groups: Group[]) {
|
|||||||
if (isCorporateUser(user)) return user.corporateInformation?.companyInformation?.name || user.name;
|
if (isCorporateUser(user)) return user.corporateInformation?.companyInformation?.name || user.name;
|
||||||
if (isAgentUser(user)) return user.agentInformation?.companyName || user.name;
|
if (isAgentUser(user)) return user.agentInformation?.companyName || user.name;
|
||||||
|
|
||||||
const belongingGroups = groups.filter((x) => x.participants.includes(user.id));
|
const belongingGroups = groups.filter((x) => x.participants.includes(user?.id));
|
||||||
const belongingGroupsAdmins = belongingGroups.map((x) => users.find((u) => u.id === x.admin)).filter((x) => !!x && isCorporateUser(x));
|
const belongingGroupsAdmins = belongingGroups.map((x) => users.find((u) => u.id === x.admin)).filter((x) => !!x && isCorporateUser(x));
|
||||||
|
|
||||||
if (belongingGroupsAdmins.length === 0) return "";
|
if (belongingGroupsAdmins.length === 0) return "";
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface PreferencesState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const initialState = {
|
export const initialState = {
|
||||||
isSidebarMinimized: false,
|
isSidebarMinimized: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const usePreferencesStore = create<PreferencesState>((set) => ({
|
const usePreferencesStore = create<PreferencesState>((set) => ({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {app} from "@/firebase";
|
import {app} from "@/firebase";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
import {collection, getDocs, getFirestore, query, where} from "firebase/firestore";
|
import {collection, getDocs, getFirestore, query, where} from "firebase/firestore";
|
||||||
|
import {getAllAssignersByCorporate} from "@/utils/groups.be";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
|
|
||||||
@@ -34,3 +35,33 @@ export const getAssignmentsByAssignerBetweenDates = async (id: string, startDate
|
|||||||
export const getAssignmentsByAssigners = async (ids: string[], startDate?: Date, endDate?: Date) => {
|
export const getAssignmentsByAssigners = async (ids: string[], startDate?: Date, endDate?: Date) => {
|
||||||
return (await Promise.all(ids.map((id) => getAssignmentsByAssigner(id, startDate, endDate)))).flat();
|
return (await Promise.all(ids.map((id) => getAssignmentsByAssigner(id, startDate, endDate)))).flat();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getAssignmentsForCorporates = async (idsList: string[], startDate?: Date, endDate?: Date) => {
|
||||||
|
const assigners = await Promise.all(
|
||||||
|
idsList.map(async (id) => {
|
||||||
|
const assigners = await getAllAssignersByCorporate(id);
|
||||||
|
return {
|
||||||
|
corporateId: id,
|
||||||
|
assigners,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const assignments = await Promise.all(
|
||||||
|
assigners.map(async (data) => {
|
||||||
|
try {
|
||||||
|
const assigners = [...new Set([...data.assigners, data.corporateId])];
|
||||||
|
const assignments = await getAssignmentsByAssigners(assigners, startDate, endDate);
|
||||||
|
return assignments.map((assignment) => ({
|
||||||
|
...assignment,
|
||||||
|
corporateId: data.corporateId,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return assignments.flat();
|
||||||
|
}
|
||||||
@@ -1,10 +1,77 @@
|
|||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
|
|
||||||
export const futureAssignmentFilter = (a: Assignment) => moment(a.startDate).isAfter(moment()) && !a.archived;
|
// export const futureAssignmentFilter = (a: Assignment) => {
|
||||||
|
// if(a.archived) return false;
|
||||||
|
// if(a.start) return false;
|
||||||
|
|
||||||
export const pastAssignmentFilter = (a: Assignment) => moment(a.endDate).isBefore(moment()) && !a.archived;
|
// const currentDate = moment();
|
||||||
|
// const startDate = moment(a.startDate);
|
||||||
|
// if(currentDate.isAfter(startDate)) return false;
|
||||||
|
// if(a.autoStart && a.autoStartDate) {
|
||||||
|
// return moment(a.autoStartDate).isAfter(currentDate);
|
||||||
|
// }
|
||||||
|
// return false;
|
||||||
|
// }
|
||||||
|
|
||||||
|
export const futureAssignmentFilter = (a: Assignment) => {
|
||||||
|
const currentDate = moment();
|
||||||
|
if(moment(a.endDate).isBefore(currentDate)) return false;
|
||||||
|
if(a.archived) return false;
|
||||||
|
|
||||||
|
if(a.autoStart && a.autoStartDate && moment(a.autoStartDate).isBefore(currentDate)) return false;
|
||||||
|
|
||||||
|
if(!a.start) {
|
||||||
|
if(moment(a.startDate).isBefore(currentDate)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pastAssignmentFilter = (a: Assignment) => {
|
||||||
|
const currentDate = moment();
|
||||||
|
if(a.archived) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return moment(a.endDate).isBefore(currentDate);
|
||||||
|
}
|
||||||
|
|
||||||
export const archivedAssignmentFilter = (a: Assignment) => a.archived;
|
export const archivedAssignmentFilter = (a: Assignment) => a.archived;
|
||||||
|
|
||||||
export const activeAssignmentFilter = (a: Assignment) => moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment());
|
export const activeAssignmentFilter = (a: Assignment) => {
|
||||||
|
const currentDate = moment();
|
||||||
|
if(moment(a.endDate).isBefore(currentDate)) return false;
|
||||||
|
if(a.archived) return false;
|
||||||
|
|
||||||
|
if(a.start) return true;
|
||||||
|
|
||||||
|
if(a.autoStart && a.autoStartDate) {
|
||||||
|
return moment(a.autoStartDate).isBefore(currentDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if(currentDate.isAfter(moment(a.startDate))) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// export const unstartedAssignmentFilter = (a: Assignment) => {
|
||||||
|
// const currentDate = moment();
|
||||||
|
// if(moment(a.endDate).isBefore(currentDate)) return false;
|
||||||
|
// if(a.archived) return false;
|
||||||
|
|
||||||
|
// if(a.autoStart && a.autoStartDate && moment(a.autoStartDate).isBefore(currentDate)) return false;
|
||||||
|
|
||||||
|
// if(!a.start) {
|
||||||
|
// if(moment(a.startDate).isBefore(currentDate)) return false;
|
||||||
|
// return true;
|
||||||
|
// }
|
||||||
|
// return false;
|
||||||
|
// }
|
||||||
|
|
||||||
|
export const startHasExpiredAssignmentFilter = (a: Assignment) => {
|
||||||
|
const currentDate = moment();
|
||||||
|
if(a.archived) return false;
|
||||||
|
if(a.start) return false;
|
||||||
|
if(currentDate.isAfter(moment(a.startDate)) && currentDate.isBefore(moment(a.endDate))) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import {shuffle} from "lodash";
|
|||||||
import {Difficulty, Exam, InstructorGender, SpeakingExam, Variant, WritingExam} from "@/interfaces/exam";
|
import {Difficulty, Exam, InstructorGender, SpeakingExam, Variant, WritingExam} from "@/interfaces/exam";
|
||||||
import {DeveloperUser, Stat, StudentUser, User} from "@/interfaces/user";
|
import {DeveloperUser, Stat, StudentUser, User} from "@/interfaces/user";
|
||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
|
import {getCorporateUser} from "@/resources/user";
|
||||||
|
import {getUserCorporate} from "./groups.be";
|
||||||
|
|
||||||
export const getExams = async (
|
export const getExams = async (
|
||||||
db: Firestore,
|
db: Firestore,
|
||||||
@@ -17,18 +19,21 @@ export const getExams = async (
|
|||||||
): Promise<Exam[]> => {
|
): Promise<Exam[]> => {
|
||||||
const moduleRef = collection(db, module);
|
const moduleRef = collection(db, module);
|
||||||
|
|
||||||
const q = query(moduleRef, and(where("isDiagnostic", "==", false), where("private", "!=", true)));
|
const q = query(moduleRef, where("isDiagnostic", "==", false));
|
||||||
const snapshot = await getDocs(q);
|
const snapshot = await getDocs(q);
|
||||||
|
|
||||||
const allExams = shuffle(
|
const allExams = (
|
||||||
snapshot.docs.map((doc) => ({
|
shuffle(
|
||||||
id: doc.id,
|
snapshot.docs.map((doc) => ({
|
||||||
...doc.data(),
|
id: doc.id,
|
||||||
module,
|
...doc.data(),
|
||||||
})),
|
module,
|
||||||
) as Exam[];
|
})),
|
||||||
|
) as Exam[]
|
||||||
|
).filter((x) => !x.private);
|
||||||
|
|
||||||
let exams: Exam[] = filterByVariant(allExams, variant);
|
let exams: Exam[] = await filterByOwners(allExams, userId);
|
||||||
|
exams = filterByVariant(exams, variant);
|
||||||
exams = filterByInstructorGender(exams, instructorGender);
|
exams = filterByInstructorGender(exams, instructorGender);
|
||||||
exams = await filterByDifficulty(db, exams, module, userId);
|
exams = await filterByDifficulty(db, exams, module, userId);
|
||||||
exams = await filterByPreference(db, exams, module, userId);
|
exams = await filterByPreference(db, exams, module, userId);
|
||||||
@@ -60,6 +65,20 @@ const filterByVariant = (exams: Exam[], variant?: Variant) => {
|
|||||||
return filtered.length > 0 ? filtered : exams;
|
return filtered.length > 0 ? filtered : exams;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filterByOwners = async (exams: Exam[], userID?: string) => {
|
||||||
|
if (!userID) return exams.filter((x) => !x.owners || x.owners.length === 0);
|
||||||
|
return await Promise.all(
|
||||||
|
exams.filter(async (x) => {
|
||||||
|
if (!x.owners) return true;
|
||||||
|
if (x.owners.length === 0) return true;
|
||||||
|
if (x.owners.includes(userID)) return true;
|
||||||
|
|
||||||
|
const corporate = await getUserCorporate(userID);
|
||||||
|
return !corporate ? false : x.owners.includes(corporate.id);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const filterByDifficulty = async (db: Firestore, exams: Exam[], module: Module, userID?: string) => {
|
const filterByDifficulty = async (db: Firestore, exams: Exam[], module: Module, userID?: string) => {
|
||||||
if (!userID) return exams;
|
if (!userID) return exams;
|
||||||
const userRef = await getDoc(doc(db, "users", userID));
|
const userRef = await getDoc(doc(db, "users", userID));
|
||||||
|
|||||||
23
src/utils/grading.be.ts
Normal file
23
src/utils/grading.be.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { app } from "@/firebase";
|
||||||
|
import { getFirestore, doc, getDoc } from "firebase/firestore";
|
||||||
|
import { CEFR_STEPS } from "@/resources/grading";
|
||||||
|
import { getUserCorporate } from "@/utils/groups.be";
|
||||||
|
import { User } from "@/interfaces/user";
|
||||||
|
import { Grading } from "@/interfaces";
|
||||||
|
const db = getFirestore(app);
|
||||||
|
|
||||||
|
export const getGradingSystem = async (user: User): Promise<Grading> => {
|
||||||
|
const snapshot = await getDoc(doc(db, "grading", user.id));
|
||||||
|
if (snapshot.exists()) return snapshot.data() as Grading;
|
||||||
|
|
||||||
|
if (user.type !== "teacher" && user.type !== "student")
|
||||||
|
return { steps: CEFR_STEPS, user: user.id };
|
||||||
|
|
||||||
|
const corporate = await getUserCorporate(user.id);
|
||||||
|
if (!corporate) return { steps: CEFR_STEPS, user: user.id };
|
||||||
|
|
||||||
|
const corporateSnapshot = await getDoc(doc(db, "grading", corporate.id));
|
||||||
|
if (corporateSnapshot.exists()) return corporateSnapshot.data() as Grading;
|
||||||
|
|
||||||
|
return { steps: CEFR_STEPS, user: user.id };
|
||||||
|
};
|
||||||
@@ -35,11 +35,12 @@ export const updateExpiryDateOnGroup = async (participantID: string, corporateID
|
|||||||
|
|
||||||
export const getUserCorporate = async (id: string) => {
|
export const getUserCorporate = async (id: string) => {
|
||||||
const user = await getUser(id);
|
const user = await getUser(id);
|
||||||
|
if (["admin", "developer"].includes(user.type)) return undefined;
|
||||||
if (user.type === "mastercorporate") return user;
|
if (user.type === "mastercorporate") return user;
|
||||||
|
|
||||||
const groups = await getParticipantGroups(id);
|
const groups = await getParticipantGroups(id);
|
||||||
const admins = await Promise.all(groups.map((x) => x.admin).map(getUser));
|
const admins = await Promise.all(groups.map((x) => x.admin).map(getUser));
|
||||||
const corporates = admins.filter((x) => x.type === "corporate" || x.type === "mastercorporate");
|
const corporates = admins.filter((x) => (user.type === "corporate" ? x.type === "mastercorporate" : x.type === "corporate"));
|
||||||
|
|
||||||
if (corporates.length === 0) return undefined;
|
if (corporates.length === 0) return undefined;
|
||||||
return corporates.shift() as CorporateUser | MasterCorporateUser;
|
return corporates.shift() as CorporateUser | MasterCorporateUser;
|
||||||
@@ -78,7 +79,7 @@ export const getAllAssignersByCorporate = async (corporateID: string): Promise<s
|
|||||||
return teacherPromises.filter((x) => !!x).flat() as string[];
|
return teacherPromises.filter((x) => !!x).flat() as string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getGroupsForUser = async (admin: string, participant?: string) => {
|
export const getGroupsForUser = async (admin?: string, participant?: string) => {
|
||||||
try {
|
try {
|
||||||
const queryConstraints = [
|
const queryConstraints = [
|
||||||
...(admin ? [where("admin", "==", admin)] : []),
|
...(admin ? [where("admin", "==", admin)] : []),
|
||||||
|
|||||||
@@ -1,37 +1,48 @@
|
|||||||
import {CorporateUser, Group, User, Type} from "@/interfaces/user";
|
import { CorporateUser, Group, User, Type } from "@/interfaces/user";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
export const isUserFromCorporate = async (userID: string) => {
|
export const isUserFromCorporate = async (userID: string) => {
|
||||||
const groups = (await axios.get<Group[]>(`/api/groups?participant=${userID}`)).data;
|
const groups = (await axios.get<Group[]>(`/api/groups?participant=${userID}`))
|
||||||
const users = (await axios.get<User[]>("/api/users/list")).data;
|
.data;
|
||||||
|
const usersData = (await axios.get<{users: User[], total: number}>("/api/users/list")).data;
|
||||||
|
|
||||||
const adminTypes = groups.map((g) => users.find((u) => u.id === g.admin)?.type);
|
const adminTypes = groups.reduce((accm: Type[], g) => {
|
||||||
return adminTypes.includes("corporate");
|
const user = usersData.users.find((u) => u.id === g.admin);
|
||||||
|
if (user) {
|
||||||
|
return [...accm, user.type];
|
||||||
|
}
|
||||||
|
|
||||||
|
return accm;
|
||||||
|
}, []);
|
||||||
|
return adminTypes.includes("corporate");
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAdminForGroup = async (userID: string, role: Type) => {
|
const getAdminForGroup = async (userID: string, role: Type) => {
|
||||||
const groups = (await axios.get<Group[]>(`/api/groups?participant=${userID}`)).data;
|
const groups = (await axios.get<Group[]>(`/api/groups?participant=${userID}`))
|
||||||
|
.data;
|
||||||
|
|
||||||
const adminRequests = await Promise.all(
|
const adminRequests = await Promise.all(
|
||||||
groups.map(async (g) => {
|
groups.map(async (g) => {
|
||||||
const userRequest = await axios.get<User>(`/api/users/${g.admin}`);
|
const userRequest = await axios.get<User>(`/api/users/${g.admin}`);
|
||||||
if (userRequest.status === 200) return userRequest.data;
|
if (userRequest.status === 200) return userRequest.data;
|
||||||
return undefined;
|
return undefined;
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const admins = adminRequests.filter((x) => x?.type === role);
|
const admins = adminRequests.filter((x) => x?.type === role);
|
||||||
return admins.length > 0 ? (admins[0] as CorporateUser) : undefined;
|
return admins.length > 0 ? (admins[0] as CorporateUser) : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getUserCorporate = async (userID: string): Promise<CorporateUser | undefined> => {
|
export const getUserCorporate = async (
|
||||||
const userRequest = await axios.get<User>(`/api/users/${userID}`);
|
userID: string
|
||||||
if (userRequest.status === 200) {
|
): Promise<CorporateUser | undefined> => {
|
||||||
const user = userRequest.data;
|
const userRequest = await axios.get<User>(`/api/users/${userID}`);
|
||||||
if (user.type === "corporate") {
|
if (userRequest.status === 200) {
|
||||||
return getAdminForGroup(userID, "mastercorporate");
|
const user = userRequest.data;
|
||||||
}
|
if (user.type === "corporate") {
|
||||||
}
|
return getAdminForGroup(userID, "mastercorporate");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return getAdminForGroup(userID, "corporate");
|
return getAdminForGroup(userID, "corporate");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ export const countExercises = (exercises: Exercise[]) => {
|
|||||||
if (e.type === "multipleChoice") return e.questions.length;
|
if (e.type === "multipleChoice") return e.questions.length;
|
||||||
if (e.type === "interactiveSpeaking") return e.prompts.length;
|
if (e.type === "interactiveSpeaking") return e.prompts.length;
|
||||||
if (e.type === "fillBlanks") return e.words.length;
|
if (e.type === "fillBlanks") return e.words.length;
|
||||||
|
if (e.type === "writeBlanks") return e.solutions.length;
|
||||||
|
if (e.type === "matchSentences") return e.sentences.length;
|
||||||
|
if (e.type === "trueFalse") return e.questions.length;
|
||||||
return 1;
|
return 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
29
src/utils/search.ts
Normal file
29
src/utils/search.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
|
||||||
|
/*fields example = [
|
||||||
|
['id'],
|
||||||
|
['companyInformation', 'companyInformation', 'name']
|
||||||
|
]*/
|
||||||
|
|
||||||
|
const getFieldValue = (fields: string[], data: any): string => {
|
||||||
|
if (fields.length === 0) return data;
|
||||||
|
const [key, ...otherFields] = fields;
|
||||||
|
|
||||||
|
if (data[key]) return getFieldValue(otherFields, data[key]);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const search = (text: string, fields: string[][], rows: any[]) => {
|
||||||
|
const searchText = text.toLowerCase();
|
||||||
|
return rows.filter((row) => {
|
||||||
|
return fields.some((fieldsKeys) => {
|
||||||
|
const value = getFieldValue(fieldsKeys, row);
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.toLowerCase().includes(searchText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return (value as Number).toString().includes(searchText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,9 +1,25 @@
|
|||||||
import {app} from "@/firebase";
|
import {app} from "@/firebase";
|
||||||
|
|
||||||
import {collection, doc, getDoc, getDocs, getFirestore, query, where} from "firebase/firestore";
|
import {
|
||||||
import {CorporateUser, Group, User} from "@/interfaces/user";
|
collection,
|
||||||
|
doc,
|
||||||
|
documentId,
|
||||||
|
endAt,
|
||||||
|
endBefore,
|
||||||
|
getCountFromServer,
|
||||||
|
getDoc,
|
||||||
|
getDocs,
|
||||||
|
getFirestore,
|
||||||
|
limit,
|
||||||
|
orderBy,
|
||||||
|
query,
|
||||||
|
startAfter,
|
||||||
|
startAt,
|
||||||
|
where,
|
||||||
|
} from "firebase/firestore";
|
||||||
|
import {CorporateUser, Group, Type, User} from "@/interfaces/user";
|
||||||
import {getGroupsForUser} from "./groups.be";
|
import {getGroupsForUser} from "./groups.be";
|
||||||
import {uniq, uniqBy} from "lodash";
|
import {last, uniq, uniqBy} from "lodash";
|
||||||
import {getUserCodes} from "./codes.be";
|
import {getUserCodes} from "./codes.be";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
@@ -38,6 +54,55 @@ export async function getSpecificUsers(ids: string[]) {
|
|||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getLinkedUsers(userID?: string, userType?: Type, type?: Type, firstID?: string, lastID?: string, size?: number) {
|
||||||
|
const q = [
|
||||||
|
...(!!type ? [where("type", "==", type)] : []),
|
||||||
|
orderBy(documentId()),
|
||||||
|
...(!!firstID && !lastID ? [endBefore(firstID)] : []),
|
||||||
|
...(!!lastID && !firstID ? [startAfter(lastID)] : []),
|
||||||
|
...(!!size ? [limit(size)] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const totalQ = [...(!!type ? [where("type", "==", type)] : []), orderBy(documentId())];
|
||||||
|
|
||||||
|
if (!userID || userType === "admin" || userType === "developer") {
|
||||||
|
const snapshot = await getDocs(query(collection(db, "users"), ...q));
|
||||||
|
const users = snapshot.docs.map((doc) => ({
|
||||||
|
id: doc.id,
|
||||||
|
...doc.data(),
|
||||||
|
})) as User[];
|
||||||
|
|
||||||
|
const total = await getCountFromServer(query(collection(db, "users"), ...totalQ));
|
||||||
|
return {users, total: total.data().count};
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminGroups = await getGroupsForUser(userID);
|
||||||
|
const groups = await Promise.all(adminGroups.flatMap((x) => x.participants).map(async (x) => await getGroupsForUser(x)));
|
||||||
|
const belongingGroups = await getGroupsForUser(undefined, userID);
|
||||||
|
|
||||||
|
const participants = uniq([
|
||||||
|
...adminGroups.flatMap((x) => x.participants),
|
||||||
|
...groups.flat().flatMap((x) => x.participants),
|
||||||
|
...(userType === "teacher" ? belongingGroups.flatMap((x) => x.participants) : []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ⨯ [FirebaseError: Invalid Query. A non-empty array is required for 'in' filters.] {
|
||||||
|
if(participants.length === 0) return {users: [], total: 0};
|
||||||
|
|
||||||
|
const snapshot = await getDocs(query(collection(db, "users"), ...[where(documentId(), "in", participants), ...q]));
|
||||||
|
const users = snapshot.docs.map((doc) => ({
|
||||||
|
id: doc.id,
|
||||||
|
...doc.data(),
|
||||||
|
})) as User[];
|
||||||
|
|
||||||
|
const total = await getCountFromServer(query(collection(db, "users"), ...[where(documentId(), "in", participants), ...totalQ]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
users,
|
||||||
|
total: total.data().count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUserBalance(user: User) {
|
export async function getUserBalance(user: User) {
|
||||||
const codes = await getUserCodes(user.id);
|
const codes = await getUserCodes(user.id);
|
||||||
if (user.type !== "corporate" && user.type !== "mastercorporate") return codes.length;
|
if (user.type !== "corporate" && user.type !== "mastercorporate") return codes.length;
|
||||||
|
|||||||
564
yarn.lock
564
yarn.lock
@@ -191,7 +191,7 @@
|
|||||||
"@emotion/utils" "0.11.3"
|
"@emotion/utils" "0.11.3"
|
||||||
"@emotion/weak-memoize" "0.2.5"
|
"@emotion/weak-memoize" "0.2.5"
|
||||||
|
|
||||||
"@emotion/cache@^11.13.0":
|
"@emotion/cache@^11.13.0", "@emotion/cache@^11.4.0":
|
||||||
version "11.13.1"
|
version "11.13.1"
|
||||||
resolved "https://registry.npmjs.org/@emotion/cache/-/cache-11.13.1.tgz"
|
resolved "https://registry.npmjs.org/@emotion/cache/-/cache-11.13.1.tgz"
|
||||||
integrity sha512-iqouYkuEblRcXmylXIwwOodiEK5Ifl7JcX7o6V4jI3iW4mLXX3dmt5xwBtIkJiQEXFAI+pC8X0i67yiPkH9Ucw==
|
integrity sha512-iqouYkuEblRcXmylXIwwOodiEK5Ifl7JcX7o6V4jI3iW4mLXX3dmt5xwBtIkJiQEXFAI+pC8X0i67yiPkH9Ucw==
|
||||||
@@ -202,27 +202,16 @@
|
|||||||
"@emotion/weak-memoize" "^0.4.0"
|
"@emotion/weak-memoize" "^0.4.0"
|
||||||
stylis "4.2.0"
|
stylis "4.2.0"
|
||||||
|
|
||||||
"@emotion/cache@^11.4.0":
|
|
||||||
version "11.13.1"
|
|
||||||
resolved "https://registry.npmjs.org/@emotion/cache/-/cache-11.13.1.tgz"
|
|
||||||
integrity sha512-iqouYkuEblRcXmylXIwwOodiEK5Ifl7JcX7o6V4jI3iW4mLXX3dmt5xwBtIkJiQEXFAI+pC8X0i67yiPkH9Ucw==
|
|
||||||
dependencies:
|
|
||||||
"@emotion/memoize" "^0.9.0"
|
|
||||||
"@emotion/sheet" "^1.4.0"
|
|
||||||
"@emotion/utils" "^1.4.0"
|
|
||||||
"@emotion/weak-memoize" "^0.4.0"
|
|
||||||
stylis "4.2.0"
|
|
||||||
|
|
||||||
"@emotion/hash@^0.9.2":
|
|
||||||
version "0.9.2"
|
|
||||||
resolved "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz"
|
|
||||||
integrity sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==
|
|
||||||
|
|
||||||
"@emotion/hash@0.8.0":
|
"@emotion/hash@0.8.0":
|
||||||
version "0.8.0"
|
version "0.8.0"
|
||||||
resolved "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz"
|
resolved "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz"
|
||||||
integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==
|
integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==
|
||||||
|
|
||||||
|
"@emotion/hash@^0.9.2":
|
||||||
|
version "0.9.2"
|
||||||
|
resolved "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz"
|
||||||
|
integrity sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==
|
||||||
|
|
||||||
"@emotion/is-prop-valid@^0.8.2":
|
"@emotion/is-prop-valid@^0.8.2":
|
||||||
version "0.8.8"
|
version "0.8.8"
|
||||||
resolved "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz"
|
resolved "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz"
|
||||||
@@ -230,16 +219,16 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@emotion/memoize" "0.7.4"
|
"@emotion/memoize" "0.7.4"
|
||||||
|
|
||||||
"@emotion/memoize@^0.9.0":
|
|
||||||
version "0.9.0"
|
|
||||||
resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz"
|
|
||||||
integrity sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==
|
|
||||||
|
|
||||||
"@emotion/memoize@0.7.4":
|
"@emotion/memoize@0.7.4":
|
||||||
version "0.7.4"
|
version "0.7.4"
|
||||||
resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz"
|
resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz"
|
||||||
integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==
|
integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==
|
||||||
|
|
||||||
|
"@emotion/memoize@^0.9.0":
|
||||||
|
version "0.9.0"
|
||||||
|
resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz"
|
||||||
|
integrity sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==
|
||||||
|
|
||||||
"@emotion/react@^11.8.1":
|
"@emotion/react@^11.8.1":
|
||||||
version "11.13.0"
|
version "11.13.0"
|
||||||
resolved "https://registry.npmjs.org/@emotion/react/-/react-11.13.0.tgz"
|
resolved "https://registry.npmjs.org/@emotion/react/-/react-11.13.0.tgz"
|
||||||
@@ -265,7 +254,7 @@
|
|||||||
"@emotion/utils" "0.11.3"
|
"@emotion/utils" "0.11.3"
|
||||||
csstype "^2.5.7"
|
csstype "^2.5.7"
|
||||||
|
|
||||||
"@emotion/serialize@^1.2.0":
|
"@emotion/serialize@^1.2.0", "@emotion/serialize@^1.3.0":
|
||||||
version "1.3.0"
|
version "1.3.0"
|
||||||
resolved "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.0.tgz"
|
resolved "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.0.tgz"
|
||||||
integrity sha512-jACuBa9SlYajnpIVXB+XOXnfJHyckDfe6fOpORIM6yhBDlqGuExvDdZYHDQGoDf3bZXGv7tNr+LpLjJqiEQ6EA==
|
integrity sha512-jACuBa9SlYajnpIVXB+XOXnfJHyckDfe6fOpORIM6yhBDlqGuExvDdZYHDQGoDf3bZXGv7tNr+LpLjJqiEQ6EA==
|
||||||
@@ -276,67 +265,56 @@
|
|||||||
"@emotion/utils" "^1.4.0"
|
"@emotion/utils" "^1.4.0"
|
||||||
csstype "^3.0.2"
|
csstype "^3.0.2"
|
||||||
|
|
||||||
"@emotion/serialize@^1.3.0":
|
|
||||||
version "1.3.0"
|
|
||||||
resolved "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.0.tgz"
|
|
||||||
integrity sha512-jACuBa9SlYajnpIVXB+XOXnfJHyckDfe6fOpORIM6yhBDlqGuExvDdZYHDQGoDf3bZXGv7tNr+LpLjJqiEQ6EA==
|
|
||||||
dependencies:
|
|
||||||
"@emotion/hash" "^0.9.2"
|
|
||||||
"@emotion/memoize" "^0.9.0"
|
|
||||||
"@emotion/unitless" "^0.9.0"
|
|
||||||
"@emotion/utils" "^1.4.0"
|
|
||||||
csstype "^3.0.2"
|
|
||||||
|
|
||||||
"@emotion/sheet@^1.4.0":
|
|
||||||
version "1.4.0"
|
|
||||||
resolved "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz"
|
|
||||||
integrity sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==
|
|
||||||
|
|
||||||
"@emotion/sheet@0.9.4":
|
"@emotion/sheet@0.9.4":
|
||||||
version "0.9.4"
|
version "0.9.4"
|
||||||
resolved "https://registry.npmjs.org/@emotion/sheet/-/sheet-0.9.4.tgz"
|
resolved "https://registry.npmjs.org/@emotion/sheet/-/sheet-0.9.4.tgz"
|
||||||
integrity sha512-zM9PFmgVSqBw4zL101Q0HrBVTGmpAxFZH/pYx/cjJT5advXguvcgjHFTCaIO3enL/xr89vK2bh0Mfyj9aa0ANA==
|
integrity sha512-zM9PFmgVSqBw4zL101Q0HrBVTGmpAxFZH/pYx/cjJT5advXguvcgjHFTCaIO3enL/xr89vK2bh0Mfyj9aa0ANA==
|
||||||
|
|
||||||
|
"@emotion/sheet@^1.4.0":
|
||||||
|
version "1.4.0"
|
||||||
|
resolved "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz"
|
||||||
|
integrity sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==
|
||||||
|
|
||||||
"@emotion/stylis@0.8.5":
|
"@emotion/stylis@0.8.5":
|
||||||
version "0.8.5"
|
version "0.8.5"
|
||||||
resolved "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz"
|
resolved "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz"
|
||||||
integrity sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==
|
integrity sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==
|
||||||
|
|
||||||
"@emotion/unitless@^0.9.0":
|
|
||||||
version "0.9.0"
|
|
||||||
resolved "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.9.0.tgz"
|
|
||||||
integrity sha512-TP6GgNZtmtFaFcsOgExdnfxLLpRDla4Q66tnenA9CktvVSdNKDvMVuUah4QvWPIpNjrWsGg3qeGo9a43QooGZQ==
|
|
||||||
|
|
||||||
"@emotion/unitless@0.7.5":
|
"@emotion/unitless@0.7.5":
|
||||||
version "0.7.5"
|
version "0.7.5"
|
||||||
resolved "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz"
|
resolved "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz"
|
||||||
integrity sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==
|
integrity sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==
|
||||||
|
|
||||||
|
"@emotion/unitless@^0.9.0":
|
||||||
|
version "0.9.0"
|
||||||
|
resolved "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.9.0.tgz"
|
||||||
|
integrity sha512-TP6GgNZtmtFaFcsOgExdnfxLLpRDla4Q66tnenA9CktvVSdNKDvMVuUah4QvWPIpNjrWsGg3qeGo9a43QooGZQ==
|
||||||
|
|
||||||
"@emotion/use-insertion-effect-with-fallbacks@^1.1.0":
|
"@emotion/use-insertion-effect-with-fallbacks@^1.1.0":
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.1.0.tgz"
|
resolved "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.1.0.tgz"
|
||||||
integrity sha512-+wBOcIV5snwGgI2ya3u99D7/FJquOIniQT1IKyDsBmEgwvpxMNeS65Oib7OnE2d2aY+3BU4OiH+0Wchf8yk3Hw==
|
integrity sha512-+wBOcIV5snwGgI2ya3u99D7/FJquOIniQT1IKyDsBmEgwvpxMNeS65Oib7OnE2d2aY+3BU4OiH+0Wchf8yk3Hw==
|
||||||
|
|
||||||
"@emotion/utils@^1.4.0":
|
|
||||||
version "1.4.0"
|
|
||||||
resolved "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.0.tgz"
|
|
||||||
integrity sha512-spEnrA1b6hDR/C68lC2M7m6ALPUHZC0lIY7jAS/B/9DuuO1ZP04eov8SMv/6fwRd8pzmsn2AuJEznRREWlQrlQ==
|
|
||||||
|
|
||||||
"@emotion/utils@0.11.3":
|
"@emotion/utils@0.11.3":
|
||||||
version "0.11.3"
|
version "0.11.3"
|
||||||
resolved "https://registry.npmjs.org/@emotion/utils/-/utils-0.11.3.tgz"
|
resolved "https://registry.npmjs.org/@emotion/utils/-/utils-0.11.3.tgz"
|
||||||
integrity sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw==
|
integrity sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw==
|
||||||
|
|
||||||
"@emotion/weak-memoize@^0.4.0":
|
"@emotion/utils@^1.4.0":
|
||||||
version "0.4.0"
|
version "1.4.0"
|
||||||
resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz"
|
resolved "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.0.tgz"
|
||||||
integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==
|
integrity sha512-spEnrA1b6hDR/C68lC2M7m6ALPUHZC0lIY7jAS/B/9DuuO1ZP04eov8SMv/6fwRd8pzmsn2AuJEznRREWlQrlQ==
|
||||||
|
|
||||||
"@emotion/weak-memoize@0.2.5":
|
"@emotion/weak-memoize@0.2.5":
|
||||||
version "0.2.5"
|
version "0.2.5"
|
||||||
resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz"
|
resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz"
|
||||||
integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==
|
integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==
|
||||||
|
|
||||||
|
"@emotion/weak-memoize@^0.4.0":
|
||||||
|
version "0.4.0"
|
||||||
|
resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz"
|
||||||
|
integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==
|
||||||
|
|
||||||
"@eslint/eslintrc@^1.4.1":
|
"@eslint/eslintrc@^1.4.1":
|
||||||
version "1.4.1"
|
version "1.4.1"
|
||||||
resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.4.1.tgz"
|
resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.4.1.tgz"
|
||||||
@@ -511,7 +489,7 @@
|
|||||||
"@firebase/util" "1.9.3"
|
"@firebase/util" "1.9.3"
|
||||||
tslib "^2.1.0"
|
tslib "^2.1.0"
|
||||||
|
|
||||||
"@firebase/database-compat@^0.3.4", "@firebase/database-compat@0.3.4":
|
"@firebase/database-compat@0.3.4", "@firebase/database-compat@^0.3.4":
|
||||||
version "0.3.4"
|
version "0.3.4"
|
||||||
resolved "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-0.3.4.tgz"
|
resolved "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-0.3.4.tgz"
|
||||||
integrity sha512-kuAW+l+sLMUKBThnvxvUZ+Q1ZrF/vFJ58iUY9kAcbX48U03nVzIF6Tmkf0p3WVQwMqiXguSgtOPIB6ZCeF+5Gg==
|
integrity sha512-kuAW+l+sLMUKBThnvxvUZ+Q1ZrF/vFJ58iUY9kAcbX48U03nVzIF6Tmkf0p3WVQwMqiXguSgtOPIB6ZCeF+5Gg==
|
||||||
@@ -523,7 +501,7 @@
|
|||||||
"@firebase/util" "1.9.3"
|
"@firebase/util" "1.9.3"
|
||||||
tslib "^2.1.0"
|
tslib "^2.1.0"
|
||||||
|
|
||||||
"@firebase/database-types@^0.10.4", "@firebase/database-types@0.10.4":
|
"@firebase/database-types@0.10.4", "@firebase/database-types@^0.10.4":
|
||||||
version "0.10.4"
|
version "0.10.4"
|
||||||
resolved "https://registry.npmjs.org/@firebase/database-types/-/database-types-0.10.4.tgz"
|
resolved "https://registry.npmjs.org/@firebase/database-types/-/database-types-0.10.4.tgz"
|
||||||
integrity sha512-dPySn0vJ/89ZeBac70T+2tWWPiJXWbmRygYv0smT5TfE3hDrQ09eKMF3Y+vMlTdrMWq7mUdYW5REWPSGH4kAZQ==
|
integrity sha512-dPySn0vJ/89ZeBac70T+2tWWPiJXWbmRygYv0smT5TfE3hDrQ09eKMF3Y+vMlTdrMWq7mUdYW5REWPSGH4kAZQ==
|
||||||
@@ -744,13 +722,6 @@
|
|||||||
node-fetch "2.6.7"
|
node-fetch "2.6.7"
|
||||||
tslib "^2.1.0"
|
tslib "^2.1.0"
|
||||||
|
|
||||||
"@firebase/util@^1.9.7":
|
|
||||||
version "1.9.7"
|
|
||||||
resolved "https://registry.npmjs.org/@firebase/util/-/util-1.9.7.tgz"
|
|
||||||
integrity sha512-fBVNH/8bRbYjqlbIhZ+lBtdAAS4WqZumx03K06/u7fJSpz1TGjEMm1ImvKD47w+xaFKIP2ori6z8BrbakRfjJA==
|
|
||||||
dependencies:
|
|
||||||
tslib "^2.1.0"
|
|
||||||
|
|
||||||
"@firebase/util@1.9.3":
|
"@firebase/util@1.9.3":
|
||||||
version "1.9.3"
|
version "1.9.3"
|
||||||
resolved "https://registry.npmjs.org/@firebase/util/-/util-1.9.3.tgz"
|
resolved "https://registry.npmjs.org/@firebase/util/-/util-1.9.3.tgz"
|
||||||
@@ -758,6 +729,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.1.0"
|
tslib "^2.1.0"
|
||||||
|
|
||||||
|
"@firebase/util@^1.9.7":
|
||||||
|
version "1.9.7"
|
||||||
|
resolved "https://registry.npmjs.org/@firebase/util/-/util-1.9.7.tgz"
|
||||||
|
integrity sha512-fBVNH/8bRbYjqlbIhZ+lBtdAAS4WqZumx03K06/u7fJSpz1TGjEMm1ImvKD47w+xaFKIP2ori6z8BrbakRfjJA==
|
||||||
|
dependencies:
|
||||||
|
tslib "^2.1.0"
|
||||||
|
|
||||||
"@firebase/webchannel-wrapper@0.9.0":
|
"@firebase/webchannel-wrapper@0.9.0":
|
||||||
version "0.9.0"
|
version "0.9.0"
|
||||||
resolved "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-0.9.0.tgz"
|
resolved "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-0.9.0.tgz"
|
||||||
@@ -1013,6 +991,46 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
glob "7.1.7"
|
glob "7.1.7"
|
||||||
|
|
||||||
|
"@next/swc-darwin-arm64@14.2.5":
|
||||||
|
version "14.2.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.5.tgz#d0a160cf78c18731c51cc0bff131c706b3e9bb05"
|
||||||
|
integrity sha512-/9zVxJ+K9lrzSGli1///ujyRfon/ZneeZ+v4ptpiPoOU+GKZnm8Wj8ELWU1Pm7GHltYRBklmXMTUqM/DqQ99FQ==
|
||||||
|
|
||||||
|
"@next/swc-darwin-x64@14.2.5":
|
||||||
|
version "14.2.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.5.tgz#eb832a992407f6e6352eed05a073379f1ce0589c"
|
||||||
|
integrity sha512-vXHOPCwfDe9qLDuq7U1OYM2wUY+KQ4Ex6ozwsKxp26BlJ6XXbHleOUldenM67JRyBfVjv371oneEvYd3H2gNSA==
|
||||||
|
|
||||||
|
"@next/swc-linux-arm64-gnu@14.2.5":
|
||||||
|
version "14.2.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.5.tgz#098fdab57a4664969bc905f5801ef5a89582c689"
|
||||||
|
integrity sha512-vlhB8wI+lj8q1ExFW8lbWutA4M2ZazQNvMWuEDqZcuJJc78iUnLdPPunBPX8rC4IgT6lIx/adB+Cwrl99MzNaA==
|
||||||
|
|
||||||
|
"@next/swc-linux-arm64-musl@14.2.5":
|
||||||
|
version "14.2.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.5.tgz#243a1cc1087fb75481726dd289c7b219fa01f2b5"
|
||||||
|
integrity sha512-NpDB9NUR2t0hXzJJwQSGu1IAOYybsfeB+LxpGsXrRIb7QOrYmidJz3shzY8cM6+rO4Aojuef0N/PEaX18pi9OA==
|
||||||
|
|
||||||
|
"@next/swc-linux-x64-gnu@14.2.5":
|
||||||
|
version "14.2.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.5.tgz#b8a2e436387ee4a52aa9719b718992e0330c4953"
|
||||||
|
integrity sha512-8XFikMSxWleYNryWIjiCX+gU201YS+erTUidKdyOVYi5qUQo/gRxv/3N1oZFCgqpesN6FPeqGM72Zve+nReVXQ==
|
||||||
|
|
||||||
|
"@next/swc-linux-x64-musl@14.2.5":
|
||||||
|
version "14.2.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.5.tgz#cb8a9adad5fb8df86112cfbd363aab5c6d32757b"
|
||||||
|
integrity sha512-6QLwi7RaYiQDcRDSU/os40r5o06b5ue7Jsk5JgdRBGGp8l37RZEh9JsLSM8QF0YDsgcosSeHjglgqi25+m04IQ==
|
||||||
|
|
||||||
|
"@next/swc-win32-arm64-msvc@14.2.5":
|
||||||
|
version "14.2.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.5.tgz#81f996c1c38ea0900d4e7719cc8814be8a835da0"
|
||||||
|
integrity sha512-1GpG2VhbspO+aYoMOQPQiqc/tG3LzmsdBH0LhnDS3JrtDx2QmzXe0B6mSZZiN3Bq7IOMXxv1nlsjzoS1+9mzZw==
|
||||||
|
|
||||||
|
"@next/swc-win32-ia32-msvc@14.2.5":
|
||||||
|
version "14.2.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.5.tgz#f61c74ce823e10b2bc150e648fc192a7056422e0"
|
||||||
|
integrity sha512-Igh9ZlxwvCDsu6438FXlQTHlRno4gFpJzqPjSIBZooD22tKeI4fE/YMRoHVJHmrQ2P5YL1DoZ0qaOKkbeFWeMg==
|
||||||
|
|
||||||
"@next/swc-win32-x64-msvc@14.2.5":
|
"@next/swc-win32-x64-msvc@14.2.5":
|
||||||
version "14.2.5"
|
version "14.2.5"
|
||||||
resolved "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.5.tgz"
|
resolved "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.5.tgz"
|
||||||
@@ -1026,7 +1044,7 @@
|
|||||||
"@nodelib/fs.stat" "2.0.5"
|
"@nodelib/fs.stat" "2.0.5"
|
||||||
run-parallel "^1.1.9"
|
run-parallel "^1.1.9"
|
||||||
|
|
||||||
"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5":
|
"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
|
||||||
version "2.0.5"
|
version "2.0.5"
|
||||||
resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
|
resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
|
||||||
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
|
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
|
||||||
@@ -1574,11 +1592,24 @@
|
|||||||
resolved "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz"
|
resolved "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz"
|
||||||
integrity sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==
|
integrity sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==
|
||||||
|
|
||||||
|
"@simbathesailor/use-what-changed@^2.0.0":
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@simbathesailor/use-what-changed/-/use-what-changed-2.0.0.tgz#7f82d78f92c8588b5fadd702065dde93bd781403"
|
||||||
|
integrity sha512-ulBNrPSvfho9UN6zS2fii3AsdEcp2fMaKeqUZZeCNPaZbB6aXyTUhpEN9atjMAbu/eyK3AY8L4SYJUG62Ekocw==
|
||||||
|
|
||||||
"@swc/counter@^0.1.3":
|
"@swc/counter@^0.1.3":
|
||||||
version "0.1.3"
|
version "0.1.3"
|
||||||
resolved "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz"
|
resolved "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz"
|
||||||
integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==
|
integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==
|
||||||
|
|
||||||
|
"@swc/helpers@0.5.5":
|
||||||
|
version "0.5.5"
|
||||||
|
resolved "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz"
|
||||||
|
integrity sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==
|
||||||
|
dependencies:
|
||||||
|
"@swc/counter" "^0.1.3"
|
||||||
|
tslib "^2.4.0"
|
||||||
|
|
||||||
"@swc/helpers@^0.4.2":
|
"@swc/helpers@^0.4.2":
|
||||||
version "0.4.14"
|
version "0.4.14"
|
||||||
resolved "https://registry.npmjs.org/@swc/helpers/-/helpers-0.4.14.tgz"
|
resolved "https://registry.npmjs.org/@swc/helpers/-/helpers-0.4.14.tgz"
|
||||||
@@ -1593,14 +1624,6 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.4.0"
|
tslib "^2.4.0"
|
||||||
|
|
||||||
"@swc/helpers@0.5.5":
|
|
||||||
version "0.5.5"
|
|
||||||
resolved "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz"
|
|
||||||
integrity sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==
|
|
||||||
dependencies:
|
|
||||||
"@swc/counter" "^0.1.3"
|
|
||||||
tslib "^2.4.0"
|
|
||||||
|
|
||||||
"@tanstack/react-table@^8.10.1":
|
"@tanstack/react-table@^8.10.1":
|
||||||
version "8.19.3"
|
version "8.19.3"
|
||||||
resolved "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.19.3.tgz"
|
resolved "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.19.3.tgz"
|
||||||
@@ -1814,7 +1837,7 @@
|
|||||||
resolved "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz"
|
resolved "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz"
|
||||||
integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==
|
integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==
|
||||||
|
|
||||||
"@types/node@*", "@types/node@>=12.12.47", "@types/node@>=13.7.0", "@types/node@>=8.1.0", "@types/node@18.13.0":
|
"@types/node@*", "@types/node@18.13.0", "@types/node@>=12.12.47", "@types/node@>=13.7.0", "@types/node@>=8.1.0":
|
||||||
version "18.13.0"
|
version "18.13.0"
|
||||||
resolved "https://registry.npmjs.org/@types/node/-/node-18.13.0.tgz"
|
resolved "https://registry.npmjs.org/@types/node/-/node-18.13.0.tgz"
|
||||||
integrity sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg==
|
integrity sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg==
|
||||||
@@ -2289,12 +2312,21 @@ axe-core@^4.6.2:
|
|||||||
resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.6.3.tgz"
|
resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.6.3.tgz"
|
||||||
integrity sha512-/BQzOX780JhsxDnPpH4ZiyrJAzcd8AfzFPkv+89veFSr1rcMjuq2JDCwypKaPeB6ljHp9KjXhPpjgCvQlWYuqg==
|
integrity sha512-/BQzOX780JhsxDnPpH4ZiyrJAzcd8AfzFPkv+89veFSr1rcMjuq2JDCwypKaPeB6ljHp9KjXhPpjgCvQlWYuqg==
|
||||||
|
|
||||||
axios@^1.3.5:
|
axios-cache-interceptor@^1:
|
||||||
version "1.3.5"
|
version "1.5.3"
|
||||||
resolved "https://registry.npmjs.org/axios/-/axios-1.3.5.tgz"
|
resolved "https://registry.yarnpkg.com/axios-cache-interceptor/-/axios-cache-interceptor-1.5.3.tgz#2083fc68aacb915240e37edcb792b4fed63540be"
|
||||||
integrity sha512-glL/PvG/E+xCWwV8S6nCHcrfg1exGx7vxyUIivIA1iL7BIh6bePylCfVHwp6k13ao7SATxB6imau2kqY+I67kw==
|
integrity sha512-kPgGId9XW7tR+VF7hgSkqF4f6FrV4ecCyKxjkD9v1hNJ4sXSAskocr7SMKaVHVvrbzVeruwB6yL6Y9/lY1ApKg==
|
||||||
dependencies:
|
dependencies:
|
||||||
follow-redirects "^1.15.0"
|
cache-parser "1.2.5"
|
||||||
|
fast-defer "1.1.8"
|
||||||
|
object-code "1.3.3"
|
||||||
|
|
||||||
|
axios@^1:
|
||||||
|
version "1.7.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.7.tgz#2f554296f9892a72ac8d8e4c5b79c14a91d0a47f"
|
||||||
|
integrity sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==
|
||||||
|
dependencies:
|
||||||
|
follow-redirects "^1.15.6"
|
||||||
form-data "^4.0.0"
|
form-data "^4.0.0"
|
||||||
proxy-from-env "^1.1.0"
|
proxy-from-env "^1.1.0"
|
||||||
|
|
||||||
@@ -2344,6 +2376,14 @@ babel-plugin-syntax-jsx@^6.18.0:
|
|||||||
resolved "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz"
|
resolved "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz"
|
||||||
integrity sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==
|
integrity sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==
|
||||||
|
|
||||||
|
babel-runtime@^6.26.0:
|
||||||
|
version "6.26.0"
|
||||||
|
resolved "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz"
|
||||||
|
integrity sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==
|
||||||
|
dependencies:
|
||||||
|
core-js "^2.4.0"
|
||||||
|
regenerator-runtime "^0.11.0"
|
||||||
|
|
||||||
balanced-match@^1.0.0:
|
balanced-match@^1.0.0:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz"
|
resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz"
|
||||||
@@ -2500,6 +2540,11 @@ busboy@1.6.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
streamsearch "^1.1.0"
|
streamsearch "^1.1.0"
|
||||||
|
|
||||||
|
cache-parser@1.2.5:
|
||||||
|
version "1.2.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/cache-parser/-/cache-parser-1.2.5.tgz#f19102a788b03055389730eb0493e463e1b379ac"
|
||||||
|
integrity sha512-Md/4VhAHByQ9frQ15WD6LrMNiVw9AEl/J7vWIXw+sxT6fSOpbtt6LHTp76vy8+bOESPBO94117Hm2bIjlI7XjA==
|
||||||
|
|
||||||
call-bind@^1.0.2, call-bind@^1.0.7:
|
call-bind@^1.0.2, call-bind@^1.0.7:
|
||||||
version "1.0.7"
|
version "1.0.7"
|
||||||
resolved "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz"
|
resolved "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz"
|
||||||
@@ -2601,7 +2646,7 @@ classnames@^2.2.6, classnames@^2.3.0, classnames@^2.5.1:
|
|||||||
resolved "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz"
|
resolved "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz"
|
||||||
integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==
|
integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==
|
||||||
|
|
||||||
client-only@0.0.1:
|
client-only@0.0.1, client-only@^0.0.1:
|
||||||
version "0.0.1"
|
version "0.0.1"
|
||||||
resolved "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz"
|
resolved "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz"
|
||||||
integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==
|
integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==
|
||||||
@@ -2624,20 +2669,16 @@ cliui@^7.0.2:
|
|||||||
strip-ansi "^6.0.0"
|
strip-ansi "^6.0.0"
|
||||||
wrap-ansi "^7.0.0"
|
wrap-ansi "^7.0.0"
|
||||||
|
|
||||||
cliui@^8.0.1:
|
|
||||||
version "8.0.1"
|
|
||||||
resolved "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz"
|
|
||||||
integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==
|
|
||||||
dependencies:
|
|
||||||
string-width "^4.2.0"
|
|
||||||
strip-ansi "^6.0.1"
|
|
||||||
wrap-ansi "^7.0.0"
|
|
||||||
|
|
||||||
clone@^2.1.2:
|
clone@^2.1.2:
|
||||||
version "2.1.2"
|
version "2.1.2"
|
||||||
resolved "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz"
|
resolved "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz"
|
||||||
integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==
|
integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==
|
||||||
|
|
||||||
|
clsx@2.0.0:
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz"
|
||||||
|
integrity sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==
|
||||||
|
|
||||||
clsx@^1.1.1:
|
clsx@^1.1.1:
|
||||||
version "1.2.1"
|
version "1.2.1"
|
||||||
resolved "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz"
|
resolved "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz"
|
||||||
@@ -2648,11 +2689,6 @@ clsx@^2.0.0, clsx@^2.1.1:
|
|||||||
resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz"
|
resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz"
|
||||||
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
|
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
|
||||||
|
|
||||||
clsx@2.0.0:
|
|
||||||
version "2.0.0"
|
|
||||||
resolved "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz"
|
|
||||||
integrity sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==
|
|
||||||
|
|
||||||
color-convert@^1.9.0:
|
color-convert@^1.9.0:
|
||||||
version "1.9.3"
|
version "1.9.3"
|
||||||
resolved "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz"
|
resolved "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz"
|
||||||
@@ -2667,16 +2703,16 @@ color-convert@^2.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
color-name "~1.1.4"
|
color-name "~1.1.4"
|
||||||
|
|
||||||
color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4:
|
|
||||||
version "1.1.4"
|
|
||||||
resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz"
|
|
||||||
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
|
|
||||||
|
|
||||||
color-name@1.1.3:
|
color-name@1.1.3:
|
||||||
version "1.1.3"
|
version "1.1.3"
|
||||||
resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz"
|
resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz"
|
||||||
integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
|
integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
|
||||||
|
|
||||||
|
color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4:
|
||||||
|
version "1.1.4"
|
||||||
|
resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz"
|
||||||
|
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
|
||||||
|
|
||||||
color-string@^1.9.1:
|
color-string@^1.9.1:
|
||||||
version "1.9.1"
|
version "1.9.1"
|
||||||
resolved "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz"
|
resolved "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz"
|
||||||
@@ -2744,6 +2780,11 @@ cookie@^0.5.0:
|
|||||||
resolved "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz"
|
resolved "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz"
|
||||||
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
|
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
|
||||||
|
|
||||||
|
core-js@^2.4.0:
|
||||||
|
version "2.6.12"
|
||||||
|
resolved "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz"
|
||||||
|
integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==
|
||||||
|
|
||||||
core-util-is@~1.0.0:
|
core-util-is@~1.0.0:
|
||||||
version "1.0.3"
|
version "1.0.3"
|
||||||
resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz"
|
resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz"
|
||||||
@@ -2897,6 +2938,13 @@ dayjs@^1.8.34:
|
|||||||
resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz"
|
resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz"
|
||||||
integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==
|
integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==
|
||||||
|
|
||||||
|
debug@4, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4:
|
||||||
|
version "4.3.4"
|
||||||
|
resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz"
|
||||||
|
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
|
||||||
|
dependencies:
|
||||||
|
ms "2.1.2"
|
||||||
|
|
||||||
debug@^3.2.7:
|
debug@^3.2.7:
|
||||||
version "3.2.7"
|
version "3.2.7"
|
||||||
resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz"
|
resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz"
|
||||||
@@ -2904,13 +2952,6 @@ debug@^3.2.7:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms "^2.1.1"
|
ms "^2.1.1"
|
||||||
|
|
||||||
debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@4:
|
|
||||||
version "4.3.4"
|
|
||||||
resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz"
|
|
||||||
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
|
|
||||||
dependencies:
|
|
||||||
ms "2.1.2"
|
|
||||||
|
|
||||||
decamelize@^1.2.0:
|
decamelize@^1.2.0:
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz"
|
resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz"
|
||||||
@@ -3085,7 +3126,7 @@ eastasianwidth@^0.2.0:
|
|||||||
resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz"
|
resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz"
|
||||||
integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==
|
integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==
|
||||||
|
|
||||||
ecdsa-sig-formatter@^1.0.11, ecdsa-sig-formatter@1.0.11:
|
ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11:
|
||||||
version "1.0.11"
|
version "1.0.11"
|
||||||
resolved "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz"
|
resolved "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz"
|
||||||
integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==
|
integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==
|
||||||
@@ -3560,6 +3601,11 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
|
|||||||
resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"
|
resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"
|
||||||
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
|
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
|
||||||
|
|
||||||
|
fast-defer@1.1.8:
|
||||||
|
version "1.1.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/fast-defer/-/fast-defer-1.1.8.tgz#940ef9597b2ea51c4cd08e99d0f2a8978fa49ba2"
|
||||||
|
integrity sha512-lEJeOH5VL5R09j6AA0D4Uvq7AgsHw0dAImQQ+F3iSyHZuAxyQfWobsagGpTcOPvJr3urmKRHrs+Gs9hV+/Qm/Q==
|
||||||
|
|
||||||
fast-glob@^3.2.11, fast-glob@^3.2.12, fast-glob@^3.2.9:
|
fast-glob@^3.2.11, fast-glob@^3.2.12, fast-glob@^3.2.9:
|
||||||
version "3.2.12"
|
version "3.2.12"
|
||||||
resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz"
|
resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz"
|
||||||
@@ -3676,6 +3722,13 @@ firebase-admin@^11.10.1:
|
|||||||
"@google-cloud/firestore" "^6.8.0"
|
"@google-cloud/firestore" "^6.8.0"
|
||||||
"@google-cloud/storage" "^6.9.5"
|
"@google-cloud/storage" "^6.9.5"
|
||||||
|
|
||||||
|
firebase-scrypt@^2.2.0:
|
||||||
|
version "2.2.0"
|
||||||
|
resolved "https://registry.npmjs.org/firebase-scrypt/-/firebase-scrypt-2.2.0.tgz"
|
||||||
|
integrity sha512-36vJZVPFepErsNw+nBjb9cpM9wYPtcxk1bKN//vLdVkNPhaw1cogzwxtMs0s+dYg1gvBDakg2Q4ch8zAWAvnxA==
|
||||||
|
dependencies:
|
||||||
|
babel-runtime "^6.26.0"
|
||||||
|
|
||||||
firebase@9.19.1:
|
firebase@9.19.1:
|
||||||
version "9.19.1"
|
version "9.19.1"
|
||||||
resolved "https://registry.npmjs.org/firebase/-/firebase-9.19.1.tgz"
|
resolved "https://registry.npmjs.org/firebase/-/firebase-9.19.1.tgz"
|
||||||
@@ -3721,10 +3774,10 @@ flatted@^3.1.0:
|
|||||||
resolved "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz"
|
resolved "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz"
|
||||||
integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==
|
integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==
|
||||||
|
|
||||||
follow-redirects@^1.15.0:
|
follow-redirects@^1.15.6:
|
||||||
version "1.15.2"
|
version "1.15.9"
|
||||||
resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz"
|
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1"
|
||||||
integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==
|
integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==
|
||||||
|
|
||||||
fontkit@^2.0.2:
|
fontkit@^2.0.2:
|
||||||
version "2.0.2"
|
version "2.0.2"
|
||||||
@@ -3826,6 +3879,11 @@ fs.realpath@^1.0.0:
|
|||||||
resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
|
resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
|
||||||
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
|
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
|
||||||
|
|
||||||
|
fsevents@~2.3.2:
|
||||||
|
version "2.3.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
|
||||||
|
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
|
||||||
|
|
||||||
fstream@^1.0.12:
|
fstream@^1.0.12:
|
||||||
version "1.0.12"
|
version "1.0.12"
|
||||||
resolved "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz"
|
resolved "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz"
|
||||||
@@ -3928,7 +3986,7 @@ get-tsconfig@^4.2.0:
|
|||||||
resolved "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.4.0.tgz"
|
resolved "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.4.0.tgz"
|
||||||
integrity sha512-0Gdjo/9+FzsYhXCEFueo2aY1z1tpXrxWZzP7k8ul9qt1U5o8rYJwTJYmaeHdrVosYIVYkOy2iwCJ9FdpocJhPQ==
|
integrity sha512-0Gdjo/9+FzsYhXCEFueo2aY1z1tpXrxWZzP7k8ul9qt1U5o8rYJwTJYmaeHdrVosYIVYkOy2iwCJ9FdpocJhPQ==
|
||||||
|
|
||||||
glob-parent@^5.1.2:
|
glob-parent@^5.1.2, glob-parent@~5.1.2:
|
||||||
version "5.1.2"
|
version "5.1.2"
|
||||||
resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz"
|
resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz"
|
||||||
integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
|
integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
|
||||||
@@ -3942,12 +4000,29 @@ glob-parent@^6.0.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-glob "^4.0.3"
|
is-glob "^4.0.3"
|
||||||
|
|
||||||
glob-parent@~5.1.2:
|
glob@7.1.6:
|
||||||
version "5.1.2"
|
version "7.1.6"
|
||||||
resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz"
|
resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz"
|
||||||
integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
|
integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
|
||||||
dependencies:
|
dependencies:
|
||||||
is-glob "^4.0.1"
|
fs.realpath "^1.0.0"
|
||||||
|
inflight "^1.0.4"
|
||||||
|
inherits "2"
|
||||||
|
minimatch "^3.0.4"
|
||||||
|
once "^1.3.0"
|
||||||
|
path-is-absolute "^1.0.0"
|
||||||
|
|
||||||
|
glob@7.1.7, glob@^7.1.3, glob@^7.1.4:
|
||||||
|
version "7.1.7"
|
||||||
|
resolved "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz"
|
||||||
|
integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==
|
||||||
|
dependencies:
|
||||||
|
fs.realpath "^1.0.0"
|
||||||
|
inflight "^1.0.4"
|
||||||
|
inherits "2"
|
||||||
|
minimatch "^3.0.4"
|
||||||
|
once "^1.3.0"
|
||||||
|
path-is-absolute "^1.0.0"
|
||||||
|
|
||||||
glob@^10.4.2:
|
glob@^10.4.2:
|
||||||
version "10.4.5"
|
version "10.4.5"
|
||||||
@@ -3961,18 +4036,6 @@ glob@^10.4.2:
|
|||||||
package-json-from-dist "^1.0.0"
|
package-json-from-dist "^1.0.0"
|
||||||
path-scurry "^1.11.1"
|
path-scurry "^1.11.1"
|
||||||
|
|
||||||
glob@^7.1.3, glob@^7.1.4, glob@7.1.7:
|
|
||||||
version "7.1.7"
|
|
||||||
resolved "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz"
|
|
||||||
integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==
|
|
||||||
dependencies:
|
|
||||||
fs.realpath "^1.0.0"
|
|
||||||
inflight "^1.0.4"
|
|
||||||
inherits "2"
|
|
||||||
minimatch "^3.0.4"
|
|
||||||
once "^1.3.0"
|
|
||||||
path-is-absolute "^1.0.0"
|
|
||||||
|
|
||||||
glob@^7.2.3:
|
glob@^7.2.3:
|
||||||
version "7.2.3"
|
version "7.2.3"
|
||||||
resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz"
|
resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz"
|
||||||
@@ -3996,18 +4059,6 @@ glob@^8.0.0:
|
|||||||
minimatch "^5.0.1"
|
minimatch "^5.0.1"
|
||||||
once "^1.3.0"
|
once "^1.3.0"
|
||||||
|
|
||||||
glob@7.1.6:
|
|
||||||
version "7.1.6"
|
|
||||||
resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz"
|
|
||||||
integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
|
|
||||||
dependencies:
|
|
||||||
fs.realpath "^1.0.0"
|
|
||||||
inflight "^1.0.4"
|
|
||||||
inherits "2"
|
|
||||||
minimatch "^3.0.4"
|
|
||||||
once "^1.3.0"
|
|
||||||
path-is-absolute "^1.0.0"
|
|
||||||
|
|
||||||
globals@^11.1.0:
|
globals@^11.1.0:
|
||||||
version "11.12.0"
|
version "11.12.0"
|
||||||
resolved "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz"
|
resolved "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz"
|
||||||
@@ -4306,7 +4357,7 @@ inflight@^1.0.4:
|
|||||||
once "^1.3.0"
|
once "^1.3.0"
|
||||||
wrappy "1"
|
wrappy "1"
|
||||||
|
|
||||||
inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.3, inherits@2:
|
inherits@2, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.3:
|
||||||
version "2.0.4"
|
version "2.0.4"
|
||||||
resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
|
resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
|
||||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||||
@@ -4994,18 +5045,18 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
js-tokens "^3.0.0 || ^4.0.0"
|
js-tokens "^3.0.0 || ^4.0.0"
|
||||||
|
|
||||||
lru-cache@^10.2.0:
|
lru-cache@6.0.0, lru-cache@^6.0.0:
|
||||||
version "10.4.3"
|
|
||||||
resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz"
|
|
||||||
integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
|
|
||||||
|
|
||||||
lru-cache@^6.0.0, lru-cache@6.0.0:
|
|
||||||
version "6.0.0"
|
version "6.0.0"
|
||||||
resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz"
|
resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz"
|
||||||
integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==
|
integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==
|
||||||
dependencies:
|
dependencies:
|
||||||
yallist "^4.0.0"
|
yallist "^4.0.0"
|
||||||
|
|
||||||
|
lru-cache@^10.2.0:
|
||||||
|
version "10.4.3"
|
||||||
|
resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz"
|
||||||
|
integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
|
||||||
|
|
||||||
lru-memoizer@^2.2.0:
|
lru-memoizer@^2.2.0:
|
||||||
version "2.3.0"
|
version "2.3.0"
|
||||||
resolved "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz"
|
resolved "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz"
|
||||||
@@ -5076,7 +5127,7 @@ micromatch@^4.0.4, micromatch@^4.0.5:
|
|||||||
braces "^3.0.2"
|
braces "^3.0.2"
|
||||||
picomatch "^2.3.1"
|
picomatch "^2.3.1"
|
||||||
|
|
||||||
"mime-db@>= 1.43.0 < 2", mime-db@1.52.0:
|
mime-db@1.52.0, "mime-db@>= 1.43.0 < 2":
|
||||||
version "1.52.0"
|
version "1.52.0"
|
||||||
resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz"
|
resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz"
|
||||||
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
|
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
|
||||||
@@ -5100,14 +5151,7 @@ minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion "^1.1.7"
|
brace-expansion "^1.1.7"
|
||||||
|
|
||||||
minimatch@^5.0.1:
|
minimatch@^5.0.1, minimatch@^5.1.0:
|
||||||
version "5.1.6"
|
|
||||||
resolved "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz"
|
|
||||||
integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==
|
|
||||||
dependencies:
|
|
||||||
brace-expansion "^2.0.1"
|
|
||||||
|
|
||||||
minimatch@^5.1.0:
|
|
||||||
version "5.1.6"
|
version "5.1.6"
|
||||||
resolved "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz"
|
resolved "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz"
|
||||||
integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==
|
integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==
|
||||||
@@ -5151,11 +5195,6 @@ minizlib@^2.1.1:
|
|||||||
minipass "^3.0.0"
|
minipass "^3.0.0"
|
||||||
yallist "^4.0.0"
|
yallist "^4.0.0"
|
||||||
|
|
||||||
mkdirp@^1.0.3, mkdirp@^1.0.4:
|
|
||||||
version "1.0.4"
|
|
||||||
resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz"
|
|
||||||
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
|
|
||||||
|
|
||||||
"mkdirp@>=0.5 0":
|
"mkdirp@>=0.5 0":
|
||||||
version "0.5.6"
|
version "0.5.6"
|
||||||
resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz"
|
resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz"
|
||||||
@@ -5163,6 +5202,11 @@ mkdirp@^1.0.3, mkdirp@^1.0.4:
|
|||||||
dependencies:
|
dependencies:
|
||||||
minimist "^1.2.6"
|
minimist "^1.2.6"
|
||||||
|
|
||||||
|
mkdirp@^1.0.3, mkdirp@^1.0.4:
|
||||||
|
version "1.0.4"
|
||||||
|
resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz"
|
||||||
|
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
|
||||||
|
|
||||||
moment-timezone@^0.5.44:
|
moment-timezone@^0.5.44:
|
||||||
version "0.5.45"
|
version "0.5.45"
|
||||||
resolved "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.45.tgz"
|
resolved "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.45.tgz"
|
||||||
@@ -5175,7 +5219,7 @@ moment@^2.29.4:
|
|||||||
resolved "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz"
|
resolved "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz"
|
||||||
integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==
|
integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==
|
||||||
|
|
||||||
ms@^2.1.1, ms@2.1.2:
|
ms@2.1.2, ms@^2.1.1:
|
||||||
version "2.1.2"
|
version "2.1.2"
|
||||||
resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz"
|
resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz"
|
||||||
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
|
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
|
||||||
@@ -5237,21 +5281,14 @@ node-addon-api@^5.0.0:
|
|||||||
resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz"
|
resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz"
|
||||||
integrity sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==
|
integrity sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==
|
||||||
|
|
||||||
node-fetch@^2.6.1, node-fetch@^2.6.7, node-fetch@2.6.7:
|
node-fetch@2.6.7, node-fetch@^2.6.1, node-fetch@^2.6.7:
|
||||||
version "2.6.7"
|
version "2.6.7"
|
||||||
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz"
|
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz"
|
||||||
integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
|
integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
whatwg-url "^5.0.0"
|
whatwg-url "^5.0.0"
|
||||||
|
|
||||||
node-fetch@^2.6.12:
|
node-fetch@^2.6.12, node-fetch@^2.6.9:
|
||||||
version "2.7.0"
|
|
||||||
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz"
|
|
||||||
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
|
|
||||||
dependencies:
|
|
||||||
whatwg-url "^5.0.0"
|
|
||||||
|
|
||||||
node-fetch@^2.6.9:
|
|
||||||
version "2.7.0"
|
version "2.7.0"
|
||||||
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz"
|
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz"
|
||||||
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
|
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
|
||||||
@@ -5322,6 +5359,11 @@ object-assign@^4.0.1, object-assign@^4.1.1:
|
|||||||
resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz"
|
resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz"
|
||||||
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
|
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
|
||||||
|
|
||||||
|
object-code@1.3.3:
|
||||||
|
version "1.3.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/object-code/-/object-code-1.3.3.tgz#cf21843ddfecce3ec73fd141f66a7f16ba0cb93e"
|
||||||
|
integrity sha512-/Ds4Xd5xzrtUOJ+xJQ57iAy0BZsZltOHssnDgcZ8DOhgh41q1YJCnTPnWdWSLkNGNnxYzhYChjc5dgC9mEERCA==
|
||||||
|
|
||||||
object-hash@^3.0.0:
|
object-hash@^3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz"
|
resolved "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz"
|
||||||
@@ -5607,7 +5649,7 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.1.0, postcss-value-parser@^
|
|||||||
resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz"
|
resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz"
|
||||||
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
|
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
|
||||||
|
|
||||||
postcss@^8, postcss@^8.0.9, postcss@^8.4.21, postcss@8.4.31:
|
postcss@8.4.31, postcss@^8, postcss@^8.0.9, postcss@^8.4.21:
|
||||||
version "8.4.31"
|
version "8.4.31"
|
||||||
resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz"
|
resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz"
|
||||||
integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==
|
integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==
|
||||||
@@ -5649,15 +5691,6 @@ promise-polyfill@^8.3.0:
|
|||||||
resolved "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz"
|
resolved "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz"
|
||||||
integrity sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==
|
integrity sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==
|
||||||
|
|
||||||
prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
|
|
||||||
version "15.8.1"
|
|
||||||
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz"
|
|
||||||
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
|
||||||
dependencies:
|
|
||||||
loose-envify "^1.4.0"
|
|
||||||
object-assign "^4.1.1"
|
|
||||||
react-is "^16.13.1"
|
|
||||||
|
|
||||||
prop-types@15.7.2:
|
prop-types@15.7.2:
|
||||||
version "15.7.2"
|
version "15.7.2"
|
||||||
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz"
|
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz"
|
||||||
@@ -5667,6 +5700,15 @@ prop-types@15.7.2:
|
|||||||
object-assign "^4.1.1"
|
object-assign "^4.1.1"
|
||||||
react-is "^16.8.1"
|
react-is "^16.8.1"
|
||||||
|
|
||||||
|
prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
|
||||||
|
version "15.8.1"
|
||||||
|
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz"
|
||||||
|
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
||||||
|
dependencies:
|
||||||
|
loose-envify "^1.4.0"
|
||||||
|
object-assign "^4.1.1"
|
||||||
|
react-is "^16.13.1"
|
||||||
|
|
||||||
proto3-json-serializer@^1.0.0:
|
proto3-json-serializer@^1.0.0:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
resolved "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-1.1.1.tgz"
|
resolved "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-1.1.1.tgz"
|
||||||
@@ -5690,6 +5732,24 @@ protobufjs-cli@1.1.1:
|
|||||||
tmp "^0.2.1"
|
tmp "^0.2.1"
|
||||||
uglify-js "^3.7.7"
|
uglify-js "^3.7.7"
|
||||||
|
|
||||||
|
protobufjs@7.2.4:
|
||||||
|
version "7.2.4"
|
||||||
|
resolved "https://registry.npmjs.org/protobufjs/-/protobufjs-7.2.4.tgz"
|
||||||
|
integrity sha512-AT+RJgD2sH8phPmCf7OUZR8xGdcJRga4+1cOaXJ64hvcSkVhNcRHOwIxUatPH15+nj59WAGTDv3LSGZPEQbJaQ==
|
||||||
|
dependencies:
|
||||||
|
"@protobufjs/aspromise" "^1.1.2"
|
||||||
|
"@protobufjs/base64" "^1.1.2"
|
||||||
|
"@protobufjs/codegen" "^2.0.4"
|
||||||
|
"@protobufjs/eventemitter" "^1.1.0"
|
||||||
|
"@protobufjs/fetch" "^1.1.0"
|
||||||
|
"@protobufjs/float" "^1.0.2"
|
||||||
|
"@protobufjs/inquire" "^1.1.0"
|
||||||
|
"@protobufjs/path" "^1.1.2"
|
||||||
|
"@protobufjs/pool" "^1.1.0"
|
||||||
|
"@protobufjs/utf8" "^1.1.0"
|
||||||
|
"@types/node" ">=13.7.0"
|
||||||
|
long "^5.0.0"
|
||||||
|
|
||||||
protobufjs@^6.11.3:
|
protobufjs@^6.11.3:
|
||||||
version "6.11.3"
|
version "6.11.3"
|
||||||
resolved "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.3.tgz"
|
resolved "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.3.tgz"
|
||||||
@@ -5745,24 +5805,6 @@ protobufjs@^7.2.5:
|
|||||||
"@types/node" ">=13.7.0"
|
"@types/node" ">=13.7.0"
|
||||||
long "^5.0.0"
|
long "^5.0.0"
|
||||||
|
|
||||||
protobufjs@7.2.4:
|
|
||||||
version "7.2.4"
|
|
||||||
resolved "https://registry.npmjs.org/protobufjs/-/protobufjs-7.2.4.tgz"
|
|
||||||
integrity sha512-AT+RJgD2sH8phPmCf7OUZR8xGdcJRga4+1cOaXJ64hvcSkVhNcRHOwIxUatPH15+nj59WAGTDv3LSGZPEQbJaQ==
|
|
||||||
dependencies:
|
|
||||||
"@protobufjs/aspromise" "^1.1.2"
|
|
||||||
"@protobufjs/base64" "^1.1.2"
|
|
||||||
"@protobufjs/codegen" "^2.0.4"
|
|
||||||
"@protobufjs/eventemitter" "^1.1.0"
|
|
||||||
"@protobufjs/fetch" "^1.1.0"
|
|
||||||
"@protobufjs/float" "^1.0.2"
|
|
||||||
"@protobufjs/inquire" "^1.1.0"
|
|
||||||
"@protobufjs/path" "^1.1.2"
|
|
||||||
"@protobufjs/pool" "^1.1.0"
|
|
||||||
"@protobufjs/utf8" "^1.1.0"
|
|
||||||
"@types/node" ">=13.7.0"
|
|
||||||
long "^5.0.0"
|
|
||||||
|
|
||||||
proxy-from-env@^1.1.0:
|
proxy-from-env@^1.1.0:
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz"
|
resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz"
|
||||||
@@ -6064,33 +6106,7 @@ read-excel-file@^5.7.1:
|
|||||||
fflate "^0.7.3"
|
fflate "^0.7.3"
|
||||||
unzipper "^0.12.2"
|
unzipper "^0.12.2"
|
||||||
|
|
||||||
readable-stream@^2.0.0:
|
readable-stream@^2.0.0, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@~2.3.6:
|
||||||
version "2.3.8"
|
|
||||||
resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz"
|
|
||||||
integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==
|
|
||||||
dependencies:
|
|
||||||
core-util-is "~1.0.0"
|
|
||||||
inherits "~2.0.3"
|
|
||||||
isarray "~1.0.0"
|
|
||||||
process-nextick-args "~2.0.0"
|
|
||||||
safe-buffer "~5.1.1"
|
|
||||||
string_decoder "~1.1.1"
|
|
||||||
util-deprecate "~1.0.1"
|
|
||||||
|
|
||||||
readable-stream@^2.0.2:
|
|
||||||
version "2.3.8"
|
|
||||||
resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz"
|
|
||||||
integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==
|
|
||||||
dependencies:
|
|
||||||
core-util-is "~1.0.0"
|
|
||||||
inherits "~2.0.3"
|
|
||||||
isarray "~1.0.0"
|
|
||||||
process-nextick-args "~2.0.0"
|
|
||||||
safe-buffer "~5.1.1"
|
|
||||||
string_decoder "~1.1.1"
|
|
||||||
util-deprecate "~1.0.1"
|
|
||||||
|
|
||||||
readable-stream@^2.0.5:
|
|
||||||
version "2.3.8"
|
version "2.3.8"
|
||||||
resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz"
|
resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz"
|
||||||
integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==
|
integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==
|
||||||
@@ -6112,19 +6128,6 @@ readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0:
|
|||||||
string_decoder "^1.1.1"
|
string_decoder "^1.1.1"
|
||||||
util-deprecate "^1.0.1"
|
util-deprecate "^1.0.1"
|
||||||
|
|
||||||
readable-stream@~2.3.6:
|
|
||||||
version "2.3.8"
|
|
||||||
resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz"
|
|
||||||
integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==
|
|
||||||
dependencies:
|
|
||||||
core-util-is "~1.0.0"
|
|
||||||
inherits "~2.0.3"
|
|
||||||
isarray "~1.0.0"
|
|
||||||
process-nextick-args "~2.0.0"
|
|
||||||
safe-buffer "~5.1.1"
|
|
||||||
string_decoder "~1.1.1"
|
|
||||||
util-deprecate "~1.0.1"
|
|
||||||
|
|
||||||
readdir-glob@^1.1.2:
|
readdir-glob@^1.1.2:
|
||||||
version "1.1.3"
|
version "1.1.3"
|
||||||
resolved "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz"
|
resolved "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz"
|
||||||
@@ -6139,6 +6142,11 @@ readdirp@~3.6.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
picomatch "^2.2.1"
|
picomatch "^2.2.1"
|
||||||
|
|
||||||
|
regenerator-runtime@^0.11.0:
|
||||||
|
version "0.11.1"
|
||||||
|
resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz"
|
||||||
|
integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==
|
||||||
|
|
||||||
regenerator-runtime@^0.13.11:
|
regenerator-runtime@^0.13.11:
|
||||||
version "0.13.11"
|
version "0.13.11"
|
||||||
resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz"
|
resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz"
|
||||||
@@ -6226,13 +6234,6 @@ reusify@^1.0.4:
|
|||||||
resolved "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz"
|
resolved "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz"
|
||||||
integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
|
integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
|
||||||
|
|
||||||
rimraf@^3.0.2:
|
|
||||||
version "3.0.2"
|
|
||||||
resolved "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz"
|
|
||||||
integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
|
|
||||||
dependencies:
|
|
||||||
glob "^7.1.3"
|
|
||||||
|
|
||||||
rimraf@2:
|
rimraf@2:
|
||||||
version "2.7.1"
|
version "2.7.1"
|
||||||
resolved "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz"
|
resolved "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz"
|
||||||
@@ -6240,6 +6241,13 @@ rimraf@2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
glob "^7.1.3"
|
glob "^7.1.3"
|
||||||
|
|
||||||
|
rimraf@^3.0.2:
|
||||||
|
version "3.0.2"
|
||||||
|
resolved "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz"
|
||||||
|
integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
|
||||||
|
dependencies:
|
||||||
|
glob "^7.1.3"
|
||||||
|
|
||||||
run-parallel@^1.1.9:
|
run-parallel@^1.1.9:
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz"
|
resolved "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz"
|
||||||
@@ -6247,7 +6255,7 @@ run-parallel@^1.1.9:
|
|||||||
dependencies:
|
dependencies:
|
||||||
queue-microtask "^1.2.2"
|
queue-microtask "^1.2.2"
|
||||||
|
|
||||||
safe-buffer@^5.0.1, safe-buffer@>=5.1.0, safe-buffer@~5.2.0:
|
safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@~5.2.0:
|
||||||
version "5.2.1"
|
version "5.2.1"
|
||||||
resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz"
|
resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz"
|
||||||
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
|
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
|
||||||
@@ -6447,20 +6455,6 @@ streamsearch@^1.1.0:
|
|||||||
resolved "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz"
|
resolved "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz"
|
||||||
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
|
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
|
||||||
|
|
||||||
string_decoder@^1.1.1:
|
|
||||||
version "1.3.0"
|
|
||||||
resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz"
|
|
||||||
integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
|
|
||||||
dependencies:
|
|
||||||
safe-buffer "~5.2.0"
|
|
||||||
|
|
||||||
string_decoder@~1.1.1:
|
|
||||||
version "1.1.1"
|
|
||||||
resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz"
|
|
||||||
integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
|
|
||||||
dependencies:
|
|
||||||
safe-buffer "~5.1.0"
|
|
||||||
|
|
||||||
"string-width-cjs@npm:string-width@^4.2.0":
|
"string-width-cjs@npm:string-width@^4.2.0":
|
||||||
version "4.2.3"
|
version "4.2.3"
|
||||||
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
|
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
|
||||||
@@ -6520,6 +6514,20 @@ string.prototype.trimstart@^1.0.6:
|
|||||||
define-properties "^1.1.4"
|
define-properties "^1.1.4"
|
||||||
es-abstract "^1.20.4"
|
es-abstract "^1.20.4"
|
||||||
|
|
||||||
|
string_decoder@^1.1.1:
|
||||||
|
version "1.3.0"
|
||||||
|
resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz"
|
||||||
|
integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
|
||||||
|
dependencies:
|
||||||
|
safe-buffer "~5.2.0"
|
||||||
|
|
||||||
|
string_decoder@~1.1.1:
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz"
|
||||||
|
integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
|
||||||
|
dependencies:
|
||||||
|
safe-buffer "~5.1.0"
|
||||||
|
|
||||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
||||||
version "6.0.1"
|
version "6.0.1"
|
||||||
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
||||||
@@ -6618,11 +6626,12 @@ svg-arc-to-cubic-bezier@^3.0.0, svg-arc-to-cubic-bezier@^3.2.0:
|
|||||||
resolved "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz"
|
resolved "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz"
|
||||||
integrity sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==
|
integrity sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==
|
||||||
|
|
||||||
swr@^2.1.3:
|
swr@^2.2.5:
|
||||||
version "2.1.3"
|
version "2.2.5"
|
||||||
resolved "https://registry.npmjs.org/swr/-/swr-2.1.3.tgz"
|
resolved "https://registry.yarnpkg.com/swr/-/swr-2.2.5.tgz#063eea0e9939f947227d5ca760cc53696f46446b"
|
||||||
integrity sha512-g3ApxIM4Fjbd6vvEAlW60hJlKcYxHb+wtehogTygrh6Jsw7wNagv9m4Oj5Gq6zvvZw0tcyhVGL9L0oISvl3sUw==
|
integrity sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==
|
||||||
dependencies:
|
dependencies:
|
||||||
|
client-only "^0.0.1"
|
||||||
use-sync-external-store "^1.2.0"
|
use-sync-external-store "^1.2.0"
|
||||||
|
|
||||||
synckit@^0.8.4:
|
synckit@^0.8.4:
|
||||||
@@ -6966,7 +6975,7 @@ use-sidecar@^1.1.2:
|
|||||||
detect-node-es "^1.1.0"
|
detect-node-es "^1.1.0"
|
||||||
tslib "^2.0.0"
|
tslib "^2.0.0"
|
||||||
|
|
||||||
use-sync-external-store@^1.2.0, use-sync-external-store@1.2.0:
|
use-sync-external-store@1.2.0, use-sync-external-store@^1.2.0:
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz"
|
resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz"
|
||||||
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
|
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
|
||||||
@@ -6976,12 +6985,7 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
|
|||||||
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
|
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
|
||||||
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
|
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
|
||||||
|
|
||||||
uuid@^8.0.0:
|
uuid@^8.0.0, uuid@^8.3.0:
|
||||||
version "8.3.2"
|
|
||||||
resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz"
|
|
||||||
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
|
|
||||||
|
|
||||||
uuid@^8.3.0:
|
|
||||||
version "8.3.2"
|
version "8.3.2"
|
||||||
resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz"
|
resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz"
|
||||||
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
|
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
|
||||||
@@ -7203,11 +7207,6 @@ yargs-parser@^20.2.2:
|
|||||||
resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz"
|
resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz"
|
||||||
integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==
|
integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==
|
||||||
|
|
||||||
yargs-parser@^21.1.1:
|
|
||||||
version "21.1.1"
|
|
||||||
resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz"
|
|
||||||
integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==
|
|
||||||
|
|
||||||
yargs@^15.3.1:
|
yargs@^15.3.1:
|
||||||
version "15.4.1"
|
version "15.4.1"
|
||||||
resolved "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz"
|
resolved "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz"
|
||||||
@@ -7238,19 +7237,6 @@ yargs@^16.2.0:
|
|||||||
y18n "^5.0.5"
|
y18n "^5.0.5"
|
||||||
yargs-parser "^20.2.2"
|
yargs-parser "^20.2.2"
|
||||||
|
|
||||||
yargs@^17.7.2:
|
|
||||||
version "17.7.2"
|
|
||||||
resolved "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz"
|
|
||||||
integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==
|
|
||||||
dependencies:
|
|
||||||
cliui "^8.0.1"
|
|
||||||
escalade "^3.1.1"
|
|
||||||
get-caller-file "^2.0.5"
|
|
||||||
require-directory "^2.1.1"
|
|
||||||
string-width "^4.2.3"
|
|
||||||
y18n "^5.0.5"
|
|
||||||
yargs-parser "^21.1.1"
|
|
||||||
|
|
||||||
yocto-queue@^0.1.0:
|
yocto-queue@^0.1.0:
|
||||||
version "0.1.0"
|
version "0.1.0"
|
||||||
resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz"
|
resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user