Merge branch 'develop'
This commit is contained in:
@@ -42,6 +42,7 @@
|
|||||||
"iron-session": "^6.3.1",
|
"iron-session": "^6.3.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
|
"moment-timezone": "^0.5.44",
|
||||||
"next": "13.1.6",
|
"next": "13.1.6",
|
||||||
"nodemailer": "^6.9.5",
|
"nodemailer": "^6.9.5",
|
||||||
"nodemailer-express-handlebars": "^6.1.0",
|
"nodemailer-express-handlebars": "^6.1.0",
|
||||||
@@ -54,6 +55,7 @@
|
|||||||
"react-csv": "^2.2.2",
|
"react-csv": "^2.2.2",
|
||||||
"react-currency-input-field": "^3.6.12",
|
"react-currency-input-field": "^3.6.12",
|
||||||
"react-datepicker": "^4.18.0",
|
"react-datepicker": "^4.18.0",
|
||||||
|
"react-diff-viewer": "^3.1.1",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-firebase-hooks": "^5.1.1",
|
"react-firebase-hooks": "^5.1.1",
|
||||||
"react-icons": "^4.8.0",
|
"react-icons": "^4.8.0",
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import axios from "axios";
|
|||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import {KeyedMutator} from "swr";
|
import {KeyedMutator} from "swr";
|
||||||
import CountrySelect from "./Low/CountrySelect";
|
import CountrySelect from "./Low/CountrySelect";
|
||||||
|
import GenderInput from "@/components/High/GenderInput";
|
||||||
|
import EmploymentStatusInput from "@/components/High/EmploymentStatusInput";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -92,73 +94,11 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
<GenderInput value={gender} onChange={setGender} />
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Gender *</label>
|
|
||||||
<RadioGroup value={gender} onChange={setGender} className="flex flex-row justify-between">
|
|
||||||
<RadioGroup.Option value="male">
|
|
||||||
{({checked}) => (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
|
||||||
)}>
|
|
||||||
Male
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</RadioGroup.Option>
|
|
||||||
<RadioGroup.Option value="female">
|
|
||||||
{({checked}) => (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
|
||||||
)}>
|
|
||||||
Female
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</RadioGroup.Option>
|
|
||||||
<RadioGroup.Option value="other">
|
|
||||||
{({checked}) => (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
|
||||||
)}>
|
|
||||||
Other
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</RadioGroup.Option>
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
{user.type === "corporate" && (
|
{user.type === "corporate" && (
|
||||||
<Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required />
|
<Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required />
|
||||||
)}
|
)}
|
||||||
{user.type !== "corporate" && (
|
{user.type !== "corporate" && <EmploymentStatusInput value={employment} onChange={setEmployment} />}
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Employment Status *</label>
|
|
||||||
<RadioGroup value={employment} onChange={setEmployment} className="grid grid-cols-2 items-center gap-4 place-items-center">
|
|
||||||
{EMPLOYMENT_STATUS.map(({status, label}) => (
|
|
||||||
<RadioGroup.Option value={status} key={status}>
|
|
||||||
{({checked}) => (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"px-6 py-4 w-44 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
!checked
|
|
||||||
? "bg-white border-mti-gray-platinum"
|
|
||||||
: "bg-mti-purple-light border-mti-purple-dark text-white",
|
|
||||||
)}>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</RadioGroup.Option>
|
|
||||||
))}
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="self-end flex justify-end w-full gap-8 absolute bottom-8 left-0 px-8">
|
<div className="self-end flex justify-end w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel,
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between w-full">
|
<div className="flex justify-between w-full">
|
||||||
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
|
<Button color="purple" variant="outline" className="max-w-[200px] w-full" onClick={onCancel}>
|
||||||
Back
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
|
<Button color="purple" className="max-w-[200px] w-full" onClick={() => onAnswer(selectedWord!)} disabled={!selectedWord}>
|
||||||
Confirm
|
Confirm
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
|
|||||||
const calculateScore = () => {
|
const calculateScore = () => {
|
||||||
const total = questions.length || 0;
|
const total = questions.length || 0;
|
||||||
const correct = answers.filter(
|
const correct = answers.filter(
|
||||||
(x) => questions.find((y) => x.id.toString() === y.id.toString())?.solution?.toLowerCase() === x.solution.toLowerCase() || false,
|
(x) =>
|
||||||
|
questions
|
||||||
|
.find((y) => x.id.toString() === y.id.toString())
|
||||||
|
?.solution?.toString()
|
||||||
|
.toLowerCase() === x.solution.toLowerCase() || false,
|
||||||
).length;
|
).length;
|
||||||
const missing = total - answers.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length;
|
const missing = total - answers.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length;
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ function Blank({
|
|||||||
const [userInput, setUserInput] = useState(userSolution || "");
|
const [userInput, setUserInput] = useState(userSolution || "");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const words = userInput.split(" ").filter((x) => x !== "");
|
const words = userInput.split(" ");
|
||||||
if (words.length >= maxWords) {
|
if (words.length > maxWords) {
|
||||||
toast.warning(`You have reached your word limit of ${maxWords} words!`, {toastId: "word-limit"});
|
toast.warning(`You have reached your word limit of ${maxWords} words!`, {toastId: "word-limit"});
|
||||||
setUserInput(words.join(" ").trim());
|
setUserInput(words.join(" ").trim());
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/components/High/EmploymentStatusInput.tsx
Normal file
32
src/components/High/EmploymentStatusInput.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import {EmploymentStatus, EMPLOYMENT_STATUS} from "@/interfaces/user";
|
||||||
|
import {RadioGroup} from "@headlessui/react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: EmploymentStatus;
|
||||||
|
onChange: (value?: EmploymentStatus) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmploymentStatusInput({value, onChange}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Employment Status *</label>
|
||||||
|
<RadioGroup value={value} onChange={onChange} className="grid grid-cols-2 items-center gap-4 place-items-center">
|
||||||
|
{EMPLOYMENT_STATUS.map(({status, label}) => (
|
||||||
|
<RadioGroup.Option value={status} key={status}>
|
||||||
|
{({checked}) => (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"px-6 py-4 w-40 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||||
|
)}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</RadioGroup.Option>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/components/High/GenderInput.tsx
Normal file
54
src/components/High/GenderInput.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import {Gender} from "@/interfaces/user";
|
||||||
|
import {RadioGroup} from "@headlessui/react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: Gender;
|
||||||
|
onChange: (value?: Gender) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GenderInput({value, onChange}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Gender *</label>
|
||||||
|
<RadioGroup value={value} onChange={onChange} className="flex flex-row gap-4 justify-between">
|
||||||
|
<RadioGroup.Option value="male">
|
||||||
|
{({checked}) => (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||||
|
)}>
|
||||||
|
Male
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</RadioGroup.Option>
|
||||||
|
<RadioGroup.Option value="female">
|
||||||
|
{({checked}) => (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||||
|
)}>
|
||||||
|
Female
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</RadioGroup.Option>
|
||||||
|
<RadioGroup.Option value="other">
|
||||||
|
{({checked}) => (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||||
|
)}>
|
||||||
|
Other
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</RadioGroup.Option>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/components/Low/TImezoneSelect.tsx
Normal file
64
src/components/Low/TImezoneSelect.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Fragment, useState } from "react";
|
||||||
|
import { Combobox, Transition } from "@headlessui/react";
|
||||||
|
import { BsChevronExpand } from "react-icons/bs";
|
||||||
|
import moment from "moment-timezone";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimezoneSelect({
|
||||||
|
value,
|
||||||
|
disabled = false,
|
||||||
|
onChange,
|
||||||
|
}: Props) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const timezones = moment.tz.names();
|
||||||
|
|
||||||
|
const filteredTimezones = query === "" ? timezones : timezones.filter((x) => x.toLowerCase().includes(query.toLowerCase()));
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Combobox value={value} onChange={onChange} disabled={disabled}>
|
||||||
|
<div className="relative mt-1">
|
||||||
|
<div className="relative w-full cursor-default overflow-hidden ">
|
||||||
|
<Combobox.Input
|
||||||
|
className="py-6 w-full px-8 text-sm font-normal placeholder:text-mti-gray-cool bg-white disabled:bg-mti-gray-platinum/40 rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-8">
|
||||||
|
<BsChevronExpand />
|
||||||
|
</Combobox.Button>
|
||||||
|
</div>
|
||||||
|
<Transition
|
||||||
|
as={Fragment}
|
||||||
|
leave="transition ease-in duration-100"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
afterLeave={() => setQuery("")}
|
||||||
|
>
|
||||||
|
<Combobox.Options className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-xl bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
||||||
|
{filteredTimezones.map((timezone: string) => (
|
||||||
|
<Combobox.Option
|
||||||
|
key={timezone}
|
||||||
|
value={timezone}
|
||||||
|
className={({ active }) =>
|
||||||
|
`relative cursor-default select-none py-2 pl-10 pr-4 ${
|
||||||
|
active
|
||||||
|
? "bg-mti-purple-light text-white"
|
||||||
|
: "text-gray-900"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{timezone}
|
||||||
|
</Combobox.Option>
|
||||||
|
))}
|
||||||
|
</Combobox.Options>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</Combobox>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,9 +7,11 @@ import {BsList} from "react-icons/bs";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import MobileMenu from "./MobileMenu";
|
import MobileMenu from "./MobileMenu";
|
||||||
import {useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {Type} from "@/interfaces/user";
|
import {Type} from "@/interfaces/user";
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
import {isUserFromCorporate} from "@/utils/groups";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -22,6 +24,7 @@ interface Props {
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
const [disablePaymentPage, setDisablePaymentPage] = useState(true);
|
||||||
|
|
||||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -44,6 +47,11 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
|||||||
return today.add(7, "days").isAfter(momentDate);
|
return today.add(7, "days").isAfter(momentDate);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user.type !== "student" && user.type !== "teacher") setDisablePaymentPage(false);
|
||||||
|
isUserFromCorporate(user.id).then((result) => setDisablePaymentPage(result));
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{user && <MobileMenu path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />}
|
{user && <MobileMenu path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />}
|
||||||
@@ -55,7 +63,7 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
|||||||
<div className="flex justify-end -md:items-center gap-4 md:w-5/6 md:mr-8">
|
<div className="flex justify-end -md:items-center gap-4 md:w-5/6 md:mr-8">
|
||||||
{showExpirationDate() && (
|
{showExpirationDate() && (
|
||||||
<Link
|
<Link
|
||||||
href="/payment"
|
href={disablePaymentPage ? "/payment" : ""}
|
||||||
data-tip="Expiry date"
|
data-tip="Expiry date"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"py-2 px-6 w-fit flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
"py-2 px-6 w-fit flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
@@ -72,7 +80,8 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
|
|||||||
<Link href={disableNavigation ? "" : "/profile"} className="flex gap-6 items-center justify-end -md:hidden">
|
<Link href={disableNavigation ? "" : "/profile"} className="flex gap-6 items-center justify-end -md:hidden">
|
||||||
<img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full object-cover" />
|
<img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full object-cover" />
|
||||||
<span className="text-right -md:hidden">
|
<span className="text-right -md:hidden">
|
||||||
{user.name} | {USER_TYPE_LABELS[user.type]}
|
{user.type === "corporate" ? `${user.corporateInformation?.companyInformation.name} |` : ""} {user.name} |{" "}
|
||||||
|
{USER_TYPE_LABELS[user.type]}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
|
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
|
||||||
|
|||||||
@@ -1,35 +1,16 @@
|
|||||||
/* 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, 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 reactStringReplace from "react-string-replace";
|
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer";
|
||||||
|
|
||||||
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 formatSolution = (solution: string, errors: {correction: string | null; misspelled: string}[]) => {
|
|
||||||
const errorRegex = new RegExp(errors.map((x) => `(${x.misspelled})`).join("|"));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{reactStringReplace(solution, errorRegex, (match) => {
|
|
||||||
const correction = errors.find((x) => x.misspelled === match)?.correction;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-tip={correction ? correction : undefined}
|
|
||||||
className={clsx("text-mti-red-light font-medium underline underline-offset-2", correction && "tooltip")}>
|
|
||||||
{match}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -85,17 +66,52 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full h-full flex flex-col gap-8">
|
<div className="w-full h-full flex flex-col gap-8">
|
||||||
{userSolutions && (
|
{userSolutions && userSolutions.length > 0 && (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full relative">
|
||||||
<span>Your answer:</span>
|
{!showDiff && (
|
||||||
<div className="w-full h-full min-h-[320px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl whitespace-pre-wrap">
|
<>
|
||||||
{userSolutions[0]!.evaluation && userSolutions[0]!.evaluation.misspelled_pairs
|
<span>Your answer:</span>
|
||||||
? formatSolution(
|
<div className="w-full h-full min-h-[320px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl whitespace-pre-wrap">
|
||||||
userSolutions[0]!.solution.replaceAll("\\n", "\n"),
|
{userSolutions[0]!.solution.replaceAll("\\n", "\n")}
|
||||||
userSolutions[0]!.evaluation.misspelled_pairs,
|
</div>
|
||||||
)
|
</>
|
||||||
: userSolutions[0]!.solution.replaceAll("\\n", "\n")}
|
)}
|
||||||
</div>
|
{showDiff && (
|
||||||
|
<>
|
||||||
|
<span>Correction:</span>
|
||||||
|
<div className="w-full h-full max-h-[320px] overflow-y-scroll scrollbar-hide cursor-text border-2 overflow-x-hidden border-mti-gray-platinum bg-white rounded-3xl">
|
||||||
|
<ReactDiffViewer
|
||||||
|
styles={{
|
||||||
|
contentText: {
|
||||||
|
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
|
||||||
|
padding: "32px 28px",
|
||||||
|
},
|
||||||
|
marker: {display: "none"},
|
||||||
|
diffRemoved: {padding: "32px 28px"},
|
||||||
|
diffAdded: {padding: "32px 28px"},
|
||||||
|
|
||||||
|
wordRemoved: {padding: "0px", display: "initial"},
|
||||||
|
wordAdded: {padding: "0px", display: "initial"},
|
||||||
|
wordDiff: {padding: "0px", display: "initial"},
|
||||||
|
}}
|
||||||
|
oldValue={userSolutions[0].solution.replaceAll("\\n", "\n")}
|
||||||
|
newValue={userSolutions[0].evaluation!.fixed_text!.replaceAll("\\n", "\n")}
|
||||||
|
splitView
|
||||||
|
hideLineNumbers
|
||||||
|
showDiffOnly={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{userSolutions[0].solution && userSolutions[0].evaluation?.fixed_text && (
|
||||||
|
<Button
|
||||||
|
color="green"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full max-w-[200px] self-end absolute -top-4 right-0 !py-2"
|
||||||
|
onClick={() => setShowDiff((prev) => !prev)}>
|
||||||
|
{showDiff ? "View answer" : "View correction"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
|
||||||
|
|||||||
@@ -30,20 +30,28 @@ export interface CommonProps {
|
|||||||
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void, updateIndex?: (internalIndex: number) => void) => {
|
export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: () => void, updateIndex?: (internalIndex: number) => void) => {
|
||||||
switch (exercise.type) {
|
switch (exercise.type) {
|
||||||
case "fillBlanks":
|
case "fillBlanks":
|
||||||
return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "trueFalse":
|
case "trueFalse":
|
||||||
return <TrueFalseSolution {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
|
return <TrueFalseSolution key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "matchSentences":
|
case "matchSentences":
|
||||||
return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "multipleChoice":
|
case "multipleChoice":
|
||||||
return <MultipleChoice {...(exercise as MultipleChoiceExercise)} updateIndex={updateIndex} onNext={onNext} onBack={onBack} />;
|
return (
|
||||||
|
<MultipleChoice
|
||||||
|
key={exercise.id}
|
||||||
|
{...(exercise as MultipleChoiceExercise)}
|
||||||
|
updateIndex={updateIndex}
|
||||||
|
onNext={onNext}
|
||||||
|
onBack={onBack}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "writeBlanks":
|
case "writeBlanks":
|
||||||
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "writing":
|
case "writing":
|
||||||
return <Writing {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
|
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "speaking":
|
case "speaking":
|
||||||
return <Speaking {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
case "interactiveSpeaking":
|
case "interactiveSpeaking":
|
||||||
return <InteractiveSpeaking {...(exercise as InteractiveSpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
return <InteractiveSpeaking key={exercise.id} {...(exercise as InteractiveSpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,11 +74,11 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
user.type === "corporate"
|
user.type === "corporate"
|
||||||
? user.corporateInformation?.companyInformation.name
|
? user.corporateInformation?.companyInformation.name
|
||||||
: user.type === "agent"
|
: user.type === "agent"
|
||||||
? user.agentInformation.companyName
|
? user.agentInformation?.companyName
|
||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
const [commercialRegistration, setCommercialRegistration] = useState(
|
const [commercialRegistration, setCommercialRegistration] = useState(
|
||||||
user.type === "agent" ? user.agentInformation.commercialRegistration : undefined,
|
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
|
||||||
);
|
);
|
||||||
const [userAmount, setUserAmount] = useState(user.type === "corporate" ? user.corporateInformation?.companyInformation.userAmount : undefined);
|
const [userAmount, setUserAmount] = useState(user.type === "corporate" ? user.corporateInformation?.companyInformation.userAmount : undefined);
|
||||||
const [paymentValue, setPaymentValue] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.value : undefined);
|
const [paymentValue, setPaymentValue] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.value : undefined);
|
||||||
@@ -236,7 +236,10 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
className={clsx(
|
||||||
|
"px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
|
||||||
|
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
||||||
|
)}
|
||||||
options={CURRENCIES_OPTIONS}
|
options={CURRENCIES_OPTIONS}
|
||||||
value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)}
|
value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)}
|
||||||
onChange={(value) => setPaymentCurrency(value?.value)}
|
onChange={(value) => setPaymentCurrency(value?.value)}
|
||||||
@@ -266,7 +269,11 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
<label className="font-normal text-base text-mti-gray-dim">Country Manager</label>
|
<label className="font-normal text-base text-mti-gray-dim">Country Manager</label>
|
||||||
{referralAgentLabel && (
|
{referralAgentLabel && (
|
||||||
<Select
|
<Select
|
||||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
className={clsx(
|
||||||
|
"px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
|
||||||
|
!["developer", "admin"].includes(loggedInUser.type) &&
|
||||||
|
"!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
|
||||||
|
)}
|
||||||
options={[
|
options={[
|
||||||
{value: "", label: "No referral"},
|
{value: "", label: "No referral"},
|
||||||
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})),
|
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})),
|
||||||
@@ -293,12 +300,12 @@ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers,
|
|||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
// editing country manager should only be available for dev/admin
|
// editing country manager should only be available for dev/admin
|
||||||
isDisabled={!['developer', 'admin'].includes(loggedInUser.type)}
|
isDisabled={!["developer", "admin"].includes(loggedInUser.type)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 w-4/12">
|
<div className="flex flex-col gap-3 w-4/12">
|
||||||
{referralAgent !== "" ? (
|
{referralAgent !== "" && loggedInUser.type !== "corporate" ? (
|
||||||
<>
|
<>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Commission</label>
|
<label className="font-normal text-base text-mti-gray-dim">Commission</label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import ProfileSummary from "@/components/ProfileSummary";
|
|||||||
import useAssignments from "@/hooks/useAssignments";
|
import useAssignments from "@/hooks/useAssignments";
|
||||||
import useStats from "@/hooks/useStats";
|
import useStats from "@/hooks/useStats";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
import {User} from "@/interfaces/user";
|
import {CorporateUser, 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 {getUserCorporate} from "@/utils/groups";
|
||||||
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
|
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
|
||||||
import {averageScore, groupBySession} from "@/utils/stats";
|
import {averageScore, groupBySession} from "@/utils/stats";
|
||||||
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
|
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js";
|
||||||
@@ -18,6 +19,7 @@ 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 {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";
|
||||||
|
|
||||||
@@ -26,6 +28,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function StudentDashboard({user}: Props) {
|
export default function StudentDashboard({user}: Props) {
|
||||||
|
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
|
||||||
|
|
||||||
const {stats} = useStats(user.id);
|
const {stats} = useStats(user.id);
|
||||||
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
|
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
|
||||||
|
|
||||||
@@ -37,6 +41,10 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||||
const setAssignment = useExamStore((state) => state.setAssignment);
|
const setAssignment = useExamStore((state) => state.setAssignment);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getUserCorporate("IXdh9EQziAVXXh0jOiC5cPVlgS82").then(setCorporateUserToShow);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
const startAssignment = (assignment: Assignment) => {
|
const startAssignment = (assignment: Assignment) => {
|
||||||
const examPromises = assignment.exams.filter((e) => e.assignee === user.id).map((e) => getExamById(e.module, e.id));
|
const examPromises = assignment.exams.filter((e) => e.assignee === user.id).map((e) => getExamById(e.module, e.id));
|
||||||
|
|
||||||
@@ -60,6 +68,11 @@ export default function StudentDashboard({user}: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{corporateUserToShow && (
|
||||||
|
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
|
||||||
|
Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<ProfileSummary
|
<ProfileSummary
|
||||||
user={user}
|
user={user}
|
||||||
items={[
|
items={[
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useStats from "@/hooks/useStats";
|
import useStats from "@/hooks/useStats";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {Group, Stat, User} from "@/interfaces/user";
|
import {CorporateUser, Group, Stat, User} from "@/interfaces/user";
|
||||||
import UserList from "@/pages/(admin)/Lists/UserList";
|
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
import {dateSorter} from "@/utils";
|
import {dateSorter} from "@/utils";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
@@ -44,6 +44,7 @@ import clsx from "clsx";
|
|||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import AssignmentCreator from "./AssignmentCreator";
|
import AssignmentCreator from "./AssignmentCreator";
|
||||||
import AssignmentView from "./AssignmentView";
|
import AssignmentView from "./AssignmentView";
|
||||||
|
import {getUserCorporate} from "@/utils/groups";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -55,6 +56,7 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
|
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
|
||||||
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
|
||||||
|
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
|
||||||
|
|
||||||
const {stats} = useStats();
|
const {stats} = useStats();
|
||||||
const {users, reload} = useUsers();
|
const {users, reload} = useUsers();
|
||||||
@@ -65,6 +67,10 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
setShowModal(!!selectedUser && page === "");
|
setShowModal(!!selectedUser && page === "");
|
||||||
}, [selectedUser, page]);
|
}, [selectedUser, page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getUserCorporate(user.id).then(setCorporateUserToShow);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id);
|
const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id);
|
||||||
|
|
||||||
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
|
||||||
@@ -226,7 +232,7 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
|
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{assignments.filter(pastFilter).map((a) => (
|
{assignments.filter(pastFilter).map((a) => (
|
||||||
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} allowDownload/>
|
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} allowDownload />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -236,7 +242,16 @@ export default function TeacherDashboard({user}: Props) {
|
|||||||
|
|
||||||
const DefaultDashboard = () => (
|
const DefaultDashboard = () => (
|
||||||
<>
|
<>
|
||||||
<section className="flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center">
|
{corporateUserToShow && (
|
||||||
|
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
|
||||||
|
Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<section
|
||||||
|
className={clsx(
|
||||||
|
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center",
|
||||||
|
!!corporateUserToShow && "mt-12 xl:mt-6",
|
||||||
|
)}>
|
||||||
<IconCard
|
<IconCard
|
||||||
onClick={() => setPage("students")}
|
onClick={() => setPage("students")}
|
||||||
Icon={BsPersonFill}
|
Icon={BsPersonFill}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{exerciseIndex === -1 && (
|
{exerciseIndex === -1 && partIndex === 0 && (
|
||||||
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
|
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
|
||||||
Start now
|
Start now
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ interface Props {
|
|||||||
summaryPNG: string;
|
summaryPNG: string;
|
||||||
summaryScore: string;
|
summaryScore: string;
|
||||||
groupScoreSummary: any[];
|
groupScoreSummary: any[];
|
||||||
|
passportId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const customStyles = StyleSheet.create({
|
const customStyles = StyleSheet.create({
|
||||||
@@ -81,6 +82,7 @@ const GroupTestReport = ({
|
|||||||
summaryPNG,
|
summaryPNG,
|
||||||
summaryScore,
|
summaryScore,
|
||||||
groupScoreSummary,
|
groupScoreSummary,
|
||||||
|
passportId,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const defaultTextStyle = [styles.textFont, { fontSize: 8 }];
|
const defaultTextStyle = [styles.textFont, { fontSize: 8 }];
|
||||||
return (
|
return (
|
||||||
@@ -114,6 +116,7 @@ const GroupTestReport = ({
|
|||||||
<Text style={defaultTextStyle}>ID: {id}</Text>
|
<Text style={defaultTextStyle}>ID: {id}</Text>
|
||||||
<Text style={defaultTextStyle}>Email: {email}</Text>
|
<Text style={defaultTextStyle}>Email: {email}</Text>
|
||||||
<Text style={defaultTextStyle}>Gender: {gender}</Text>
|
<Text style={defaultTextStyle}>Gender: {gender}</Text>
|
||||||
|
<Text style={defaultTextStyle}>Passport ID: {passportId}</Text>
|
||||||
<Text style={defaultTextStyle}>
|
<Text style={defaultTextStyle}>
|
||||||
Total Number of Students: {numberOfStudents}
|
Total Number of Students: {numberOfStudents}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -203,7 +206,7 @@ const GroupTestReport = ({
|
|||||||
percentage={percent}
|
percentage={percent}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text style={[customStyles.tableCell, { maxWidth: "24px" }]}>
|
<Text style={[customStyles.tableCell, { maxWidth: "48px" }]}>
|
||||||
{percent}%
|
{percent}%
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={customStyles.tableCell}>{description}</Text>
|
<Text style={customStyles.tableCell}>{description}</Text>
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export const styles = StyleSheet.create({
|
|||||||
fontFamily: "Helvetica-Bold",
|
fontFamily: "Helvetica-Bold",
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
},
|
},
|
||||||
|
textNormal: {
|
||||||
|
fontWeight: "normal",
|
||||||
|
},
|
||||||
textColor: {
|
textColor: {
|
||||||
color: "#4e4969",
|
color: "#4e4969",
|
||||||
},
|
},
|
||||||
@@ -55,7 +58,6 @@ export const styles = StyleSheet.create({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 4,
|
|
||||||
position: "relative",
|
position: "relative",
|
||||||
},
|
},
|
||||||
radialResultContainer: {
|
radialResultContainer: {
|
||||||
|
|||||||
@@ -18,18 +18,18 @@ const TestReportFooter = () => (
|
|||||||
>
|
>
|
||||||
<View style={[styles.spacedRow, styles.textMargin]}>
|
<View style={[styles.spacedRow, styles.textMargin]}>
|
||||||
<View>
|
<View>
|
||||||
<Text>Validity</Text>
|
<Text style={styles.textBold}>Validity</Text>
|
||||||
<Text>
|
<Text>
|
||||||
This report remains valid for a duration of three months from the test
|
This report remains valid for a duration of three months from the test
|
||||||
date.
|
date.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View>
|
<View>
|
||||||
<Text>Confidential – circulated for concern people</Text>
|
<Text style={styles.textBold}>Confidential – <Text style={[styles.textFont, styles.textNormal]}>circulated for concern people</Text></Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={{ paddingTop: 10 }}>
|
<View style={{ paddingTop: 10 }}>
|
||||||
<Text>Declaration</Text>
|
<Text style={styles.textBold}>Declaration</Text>
|
||||||
<Text style={{ paddingTop: 5 }}>
|
<Text style={{ paddingTop: 5 }}>
|
||||||
We hereby declare that exam results on our platform, assessed by AI, are
|
We hereby declare that exam results on our platform, assessed by AI, are
|
||||||
not the sole determinants of candidates' English proficiency
|
not the sole determinants of candidates' English proficiency
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ interface Props {
|
|||||||
qrcode: string;
|
qrcode: string;
|
||||||
renderDetails: React.ReactNode;
|
renderDetails: React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
|
summaryPNG: string;
|
||||||
|
summaryScore: string;
|
||||||
|
passportId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TestReport = ({
|
const TestReport = ({
|
||||||
@@ -39,6 +42,9 @@ const TestReport = ({
|
|||||||
logo,
|
logo,
|
||||||
qrcode,
|
qrcode,
|
||||||
renderDetails,
|
renderDetails,
|
||||||
|
summaryPNG,
|
||||||
|
summaryScore,
|
||||||
|
passportId,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const defaultTextStyle = [styles.textFont, { fontSize: 8 }];
|
const defaultTextStyle = [styles.textFont, { fontSize: 8 }];
|
||||||
const defaultSkillsTextStyle = [styles.textFont, { fontSize: 8 }];
|
const defaultSkillsTextStyle = [styles.textFont, { fontSize: 8 }];
|
||||||
@@ -79,6 +85,7 @@ const TestReport = ({
|
|||||||
<Text style={defaultTextStyle}>ID: {id}</Text>
|
<Text style={defaultTextStyle}>ID: {id}</Text>
|
||||||
<Text style={defaultTextStyle}>Email: {email}</Text>
|
<Text style={defaultTextStyle}>Email: {email}</Text>
|
||||||
<Text style={defaultTextStyle}>Gender: {gender}</Text>
|
<Text style={defaultTextStyle}>Gender: {gender}</Text>
|
||||||
|
<Text style={defaultTextStyle}>Passport ID: {passportId}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={{ height: "120px" }}>
|
<View style={{ height: "120px" }}>
|
||||||
<Text
|
<Text
|
||||||
@@ -104,8 +111,16 @@ const TestReport = ({
|
|||||||
>
|
>
|
||||||
Performance Summary
|
Performance Summary
|
||||||
</Text>
|
</Text>
|
||||||
<View>
|
<View style={{ display: "flex", flexDirection: "row", gap: 16 }}>
|
||||||
<Text style={[styles.textFont, { fontSize: 8 }]}>{summary}</Text>
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={[styles.textFont, { fontSize: 8 }]}>{summary}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.textFont, styles.radialContainer]}>
|
||||||
|
<Image src={summaryPNG} style={styles.image64}></Image>
|
||||||
|
<View style={[styles.textColor, styles.radialResultContainer]}>
|
||||||
|
<Text style={styles.textBold}>{summaryScore}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={[{ paddingTop: 30 }, styles.separator]}></View>
|
<View style={[{ paddingTop: 30 }, styles.separator]}></View>
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ export default function useUser({redirectTo = "", redirectIfFound = false} = {})
|
|||||||
if (!redirectTo || !user) return;
|
if (!redirectTo || !user) return;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
// If redirectTo is set, redirect if the user was not found.
|
|
||||||
(redirectTo && !redirectIfFound && (!user || (user && !user.isVerified))) ||
|
|
||||||
// If redirectIfFound is also set, redirect if the user was found
|
// If redirectIfFound is also set, redirect if the user was found
|
||||||
(redirectIfFound && user && user.isVerified)
|
(redirectIfFound && user && user.isVerified) ||
|
||||||
|
// If redirectTo is set, redirect if the user was not found.
|
||||||
|
(redirectTo && !redirectIfFound && (!user || (user && !user.isVerified)))
|
||||||
) {
|
) {
|
||||||
Router.push(redirectTo);
|
Router.push(redirectTo);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {Module} from ".";
|
import {Module} from ".";
|
||||||
|
|
||||||
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
|
||||||
|
export type Variant = "diagnostic" | "partial";
|
||||||
|
|
||||||
export interface ReadingExam {
|
export interface ReadingExam {
|
||||||
parts: ReadingPart[];
|
parts: ReadingPart[];
|
||||||
@@ -9,6 +10,7 @@ export interface ReadingExam {
|
|||||||
minTimer: number;
|
minTimer: number;
|
||||||
type: "academic" | "general";
|
type: "academic" | "general";
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
|
variant?: Variant;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReadingPart {
|
export interface ReadingPart {
|
||||||
@@ -25,6 +27,7 @@ export interface LevelExam {
|
|||||||
exercises: Exercise[];
|
exercises: Exercise[];
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
|
variant?: Variant;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListeningExam {
|
export interface ListeningExam {
|
||||||
@@ -33,6 +36,7 @@ export interface ListeningExam {
|
|||||||
module: "listening";
|
module: "listening";
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
|
variant?: Variant;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListeningPart {
|
export interface ListeningPart {
|
||||||
@@ -63,6 +67,7 @@ export interface WritingExam {
|
|||||||
exercises: Exercise[];
|
exercises: Exercise[];
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
|
variant?: Variant;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WordCounter {
|
interface WordCounter {
|
||||||
@@ -76,6 +81,7 @@ export interface SpeakingExam {
|
|||||||
exercises: Exercise[];
|
exercises: Exercise[];
|
||||||
minTimer: number;
|
minTimer: number;
|
||||||
isDiagnostic: boolean;
|
isDiagnostic: boolean;
|
||||||
|
variant?: Variant;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Exercise =
|
export type Exercise =
|
||||||
@@ -104,6 +110,7 @@ interface InteractiveSpeakingEvaluation extends Evaluation {
|
|||||||
interface CommonEvaluation extends Evaluation {
|
interface CommonEvaluation extends Evaluation {
|
||||||
perfect_answer?: string;
|
perfect_answer?: string;
|
||||||
perfect_answer_1?: string;
|
perfect_answer_1?: string;
|
||||||
|
fixed_text?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WritingExercise {
|
export interface WritingExercise {
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export interface DemographicInformation {
|
|||||||
gender: Gender;
|
gender: Gender;
|
||||||
employment: EmploymentStatus;
|
employment: EmploymentStatus;
|
||||||
passport_id?: string;
|
passport_id?: string;
|
||||||
|
timezone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DemographicCorporateInformation {
|
export interface DemographicCorporateInformation {
|
||||||
@@ -85,6 +86,7 @@ export interface DemographicCorporateInformation {
|
|||||||
phone: string;
|
phone: string;
|
||||||
gender: Gender;
|
gender: Gender;
|
||||||
position: string;
|
position: string;
|
||||||
|
timezone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Gender = "male" | "female" | "other";
|
export type Gender = "male" | "female" | "other";
|
||||||
|
|||||||
@@ -14,19 +14,31 @@ import {toast} from "react-toastify";
|
|||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
import {useFilePicker} from "use-file-picker";
|
import {useFilePicker} from "use-file-picker";
|
||||||
import readXlsxFile from "read-excel-file";
|
import readXlsxFile from "read-excel-file";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import {BsQuestionCircleFill} from "react-icons/bs";
|
||||||
|
|
||||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
||||||
|
|
||||||
|
const USER_TYPE_PERMISSIONS: {[key in Type]: Type[]} = {
|
||||||
|
student: [],
|
||||||
|
teacher: [],
|
||||||
|
agent: [],
|
||||||
|
corporate: ["student", "teacher"],
|
||||||
|
admin: ["student", "teacher", "agent", "corporate", "admin"],
|
||||||
|
developer: ["student", "teacher", "agent", "corporate", "admin", "developer"],
|
||||||
|
};
|
||||||
|
|
||||||
export default function BatchCodeGenerator({user}: {user: User}) {
|
export default function BatchCodeGenerator({user}: {user: User}) {
|
||||||
const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]);
|
const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
||||||
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
|
||||||
const [type, setType] = useState<Type>("student");
|
const [type, setType] = useState<Type>("student");
|
||||||
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
|
||||||
const {users} = useUsers();
|
const {users} = useUsers();
|
||||||
|
|
||||||
const {openFilePicker, filesContent} = useFilePicker({
|
const {openFilePicker, filesContent, clear} = useFilePicker({
|
||||||
accept: ".xlsx",
|
accept: ".xlsx",
|
||||||
multiple: false,
|
multiple: false,
|
||||||
readAs: "ArrayBuffer",
|
readAs: "ArrayBuffer",
|
||||||
@@ -52,9 +64,9 @@ export default function BatchCodeGenerator({user}: {user: User}) {
|
|||||||
const [firstName, lastName, country, passport_id, email, phone] = row as string[];
|
const [firstName, lastName, country, passport_id, email, phone] = row as string[];
|
||||||
return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email)
|
return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email)
|
||||||
? {
|
? {
|
||||||
email,
|
email: email.toString(),
|
||||||
name: `${firstName} ${lastName}`,
|
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
||||||
passport_id,
|
passport_id: passport_id.toString(),
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
})
|
})
|
||||||
@@ -62,10 +74,12 @@ export default function BatchCodeGenerator({user}: {user: User}) {
|
|||||||
(x) => x.email,
|
(x) => x.email,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (information.length === 0)
|
if (information.length === 0) {
|
||||||
return toast.error(
|
toast.error(
|
||||||
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
|
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
|
||||||
);
|
);
|
||||||
|
return clear();
|
||||||
|
}
|
||||||
|
|
||||||
setInfos(information);
|
setInfos(information);
|
||||||
});
|
});
|
||||||
@@ -102,50 +116,85 @@ export default function BatchCodeGenerator({user}: {user: User}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
<>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Choose an Excel file containing e-mails</label>
|
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)} title="Excel File Format">
|
||||||
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
|
<div className="mt-4 flex flex-col gap-2">
|
||||||
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
<span>Please upload an Excel file with the following format:</span>
|
||||||
</Button>
|
<table className="w-full">
|
||||||
{user && (user.type === "developer" || user.type === "admin") && (
|
<thead>
|
||||||
<>
|
<tr>
|
||||||
<div className="flex -md:flex-row md:flex-col -md:items-center 2xl:flex-row 2xl:items-center justify-between gap-2">
|
<th className="border border-neutral-200 px-2 py-1">First Name</th>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
|
<th className="border border-neutral-200 px-2 py-1">Last Name</th>
|
||||||
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled}>
|
<th className="border border-neutral-200 px-2 py-1">Country</th>
|
||||||
Enabled
|
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
|
||||||
</Checkbox>
|
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
|
||||||
|
<th className="border border-neutral-200 px-2 py-1">Phone Number</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
<span className="mt-4">
|
||||||
|
<b>Notes:</b>
|
||||||
|
<ul>
|
||||||
|
<li>- All incorrect e-mails will be ignored;</li>
|
||||||
|
<li>- All already registered e-mails will be ignored;</li>
|
||||||
|
<li>- You may have a header row with the format above, however, it is not necessary;</li>
|
||||||
|
<li>- All of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.</li>
|
||||||
|
</ul>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
|
||||||
|
<div className="flex justify-between items-end">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Choose an Excel file</label>
|
||||||
|
<div className="cursor-pointer tooltip" data-tip="Excel File Format" onClick={() => setShowHelp(true)}>
|
||||||
|
<BsQuestionCircleFill />
|
||||||
</div>
|
</div>
|
||||||
{isExpiryDateEnabled && (
|
</div>
|
||||||
<ReactDatePicker
|
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
|
||||||
className={clsx(
|
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
|
||||||
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
</Button>
|
||||||
"hover:border-mti-purple tooltip",
|
{user && (user.type === "developer" || user.type === "admin") && (
|
||||||
"transition duration-300 ease-in-out",
|
<>
|
||||||
)}
|
<div className="flex -md:flex-row md:flex-col -md:items-center 2xl:flex-row 2xl:items-center justify-between gap-2">
|
||||||
filterDate={(date) => moment(date).isAfter(new Date())}
|
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
|
||||||
dateFormat="dd/MM/yyyy"
|
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled}>
|
||||||
selected={expiryDate}
|
Enabled
|
||||||
onChange={(date) => setExpiryDate(date)}
|
</Checkbox>
|
||||||
/>
|
</div>
|
||||||
)}
|
{isExpiryDateEnabled && (
|
||||||
</>
|
<ReactDatePicker
|
||||||
)}
|
className={clsx(
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Select the type of user they should be</label>
|
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
{user && (
|
"hover:border-mti-purple tooltip",
|
||||||
<select
|
"transition duration-300 ease-in-out",
|
||||||
defaultValue="student"
|
)}
|
||||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
filterDate={(date) => moment(date).isAfter(new Date())}
|
||||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
dateFormat="dd/MM/yyyy"
|
||||||
{Object.keys(USER_TYPE_LABELS).map((type) => (
|
selected={expiryDate}
|
||||||
<option key={type} value={type}>
|
onChange={(date) => setExpiryDate(date)}
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
/>
|
||||||
</option>
|
)}
|
||||||
))}
|
</>
|
||||||
</select>
|
)}
|
||||||
)}
|
<label className="font-normal text-base text-mti-gray-dim">Select the type of user they should be</label>
|
||||||
<Button onClick={() => generateCode(type)} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
|
{user && (
|
||||||
Generate & Send
|
<select
|
||||||
</Button>
|
defaultValue="student"
|
||||||
</div>
|
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||||
|
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
||||||
|
{Object.keys(USER_TYPE_LABELS)
|
||||||
|
.filter((x) => USER_TYPE_PERMISSIONS[user.type].includes(x as Type))
|
||||||
|
.map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<Button onClick={() => generateCode(type)} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
|
||||||
|
Generate & Send
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ import ReactDatePicker from "react-datepicker";
|
|||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
|
|
||||||
|
const USER_TYPE_PERMISSIONS: {[key in Type]: Type[]} = {
|
||||||
|
student: [],
|
||||||
|
teacher: [],
|
||||||
|
agent: [],
|
||||||
|
corporate: ["student", "teacher"],
|
||||||
|
admin: ["student", "teacher", "agent", "corporate", "admin"],
|
||||||
|
developer: ["student", "teacher", "agent", "corporate", "admin", "developer"],
|
||||||
|
};
|
||||||
|
|
||||||
export default function CodeGenerator({user}: {user: User}) {
|
export default function CodeGenerator({user}: {user: User}) {
|
||||||
const [generatedCode, setGeneratedCode] = useState<string>();
|
const [generatedCode, setGeneratedCode] = useState<string>();
|
||||||
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
||||||
@@ -63,11 +72,13 @@ export default function CodeGenerator({user}: {user: User}) {
|
|||||||
defaultValue="student"
|
defaultValue="student"
|
||||||
onChange={(e) => setType(e.target.value as typeof user.type)}
|
onChange={(e) => setType(e.target.value as typeof user.type)}
|
||||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
||||||
{Object.keys(USER_TYPE_LABELS).map((type) => (
|
{Object.keys(USER_TYPE_LABELS)
|
||||||
<option key={type} value={type}>
|
.filter((x) => USER_TYPE_PERMISSIONS[user.type].includes(x as Type))
|
||||||
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
.map((type) => (
|
||||||
</option>
|
<option key={type} value={type}>
|
||||||
))}
|
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
{user && (user.type === "developer" || user.type === "admin") && (
|
{user && (user.type === "developer" || user.type === "admin") && (
|
||||||
|
|||||||
@@ -9,16 +9,18 @@ import {Disclosure, Transition} from "@headlessui/react";
|
|||||||
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, uniqBy} from "lodash";
|
||||||
import {useEffect, useRef, useState} from "react";
|
import {useEffect, useRef, useState} from "react";
|
||||||
import {BsCheck, BsDash, BsPencil, BsPlus, BsTrash} from "react-icons/bs";
|
import {BsCheck, BsDash, BsPencil, BsPlus, BsQuestionCircleFill, BsTrash} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
import {uuidv4} from "@firebase/util";
|
import {uuidv4} from "@firebase/util";
|
||||||
import {useFilePicker} from "use-file-picker";
|
import {useFilePicker} from "use-file-picker";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
|
import readXlsxFile from "read-excel-file";
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<Group>();
|
const columnHelper = createColumnHelper<Group>();
|
||||||
|
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
|
||||||
|
|
||||||
interface CreateDialogProps {
|
interface CreateDialogProps {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -31,40 +33,49 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
|||||||
const [name, setName] = useState<string | undefined>(group?.name || undefined);
|
const [name, setName] = useState<string | undefined>(group?.name || undefined);
|
||||||
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
||||||
const [participants, setParticipants] = useState<string[]>(group?.participants || []);
|
const [participants, setParticipants] = useState<string[]>(group?.participants || []);
|
||||||
const {openFilePicker, filesContent} = useFilePicker({
|
const {openFilePicker, filesContent, clear} = useFilePicker({
|
||||||
accept: ".txt",
|
accept: ".xlsx",
|
||||||
multiple: false,
|
multiple: false,
|
||||||
|
readAs: "ArrayBuffer",
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filesContent.length > 0) {
|
if (filesContent.length > 0) {
|
||||||
const file = filesContent[0];
|
const file = filesContent[0];
|
||||||
const emails = file.content
|
readXlsxFile(file.content).then((rows) => {
|
||||||
.toLowerCase()
|
const emails = uniq(
|
||||||
.split("\n")
|
rows
|
||||||
.filter((x) => new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/).test(x));
|
.map((row) => {
|
||||||
|
const [email] = row as string[];
|
||||||
|
return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email) ? email.toString().trim() : undefined;
|
||||||
|
})
|
||||||
|
.filter((x) => !!x),
|
||||||
|
);
|
||||||
|
|
||||||
if (emails.length === 0) {
|
if (emails.length === 0) {
|
||||||
toast.error("Please upload a .txt file containing e-mails, one per line!");
|
toast.error("Please upload an Excel file containing e-mails!");
|
||||||
return;
|
clear();
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
|
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
|
||||||
const filteredUsers = emailUsers.filter(
|
const filteredUsers = emailUsers.filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
((user.type === "developer" || user.type === "admin" || user.type === "corporate") &&
|
((user.type === "developer" || user.type === "admin" || user.type === "corporate") &&
|
||||||
(x?.type === "student" || x?.type === "teacher")) ||
|
(x?.type === "student" || x?.type === "teacher")) ||
|
||||||
(user.type === "teacher" && x?.type === "student"),
|
(user.type === "teacher" && x?.type === "student"),
|
||||||
);
|
);
|
||||||
|
|
||||||
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
|
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
|
||||||
toast.success(
|
toast.success(
|
||||||
user.type !== "teacher"
|
user.type !== "teacher"
|
||||||
? "Added all teachers and students found in the file you've provided!"
|
? "Added all teachers and students found in the file you've provided!"
|
||||||
: "Added all students found in the file you've provided!",
|
: "Added all students found in the file you've provided!",
|
||||||
{toastId: "upload-success"},
|
{toastId: "upload-success"},
|
||||||
);
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filesContent, user.type, users]);
|
}, [filesContent, user.type, users]);
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
@@ -90,7 +101,12 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
|||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
<Input name="name" type="text" label="Name" defaultValue={name} onChange={setName} required disabled={group?.disableEditing} />
|
<Input name="name" type="text" label="Name" defaultValue={name} onChange={setName} required disabled={group?.disableEditing} />
|
||||||
<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">Participants</label>
|
<div className="flex gap-2 items-center">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Participants</label>
|
||||||
|
<div className="tooltip" data-tip="The Excel file should only include a column with the desired e-mails.">
|
||||||
|
<BsQuestionCircleFill />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex gap-8 w-full">
|
<div className="flex gap-8 w-full">
|
||||||
<Select
|
<Select
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -119,9 +135,11 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
|||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button className="w-full max-w-[300px]" onClick={openFilePicker} variant="outline">
|
{user.type !== "teacher" && (
|
||||||
{filesContent.length === 0 ? "Upload participants .txt file" : filesContent[0].name}
|
<Button className="w-full max-w-[300px]" onClick={openFilePicker} variant="outline">
|
||||||
</Button>
|
{filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export default function ExamPage({page}: Props) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => setSessionId(uuidv4()), []);
|
useEffect(() => setSessionId(uuidv4()), []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.type === "developer") console.log(exam);
|
||||||
|
}, [exam, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectedModules.length > 0 && timeSpent === 0 && !showSolutions;
|
selectedModules.length > 0 && timeSpent === 0 && !showSolutions;
|
||||||
@@ -64,6 +67,10 @@ export default function ExamPage({page}: Props) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedModules.length]);
|
}, [selectedModules.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showSolutions) setModuleIndex(-1);
|
||||||
|
}, [showSolutions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
|
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
|
||||||
@@ -247,14 +254,15 @@ export default function ExamPage({page}: Props) {
|
|||||||
user={user!}
|
user={user!}
|
||||||
disableSelection={page === "exams"}
|
disableSelection={page === "exams"}
|
||||||
onStart={(modules, avoid) => {
|
onStart={(modules, avoid) => {
|
||||||
setSelectedModules(modules);
|
setModuleIndex(0);
|
||||||
setAvoidRepeated(avoid);
|
setAvoidRepeated(avoid);
|
||||||
|
setSelectedModules(modules);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (moduleIndex >= selectedModules.length) {
|
if (moduleIndex >= selectedModules.length || moduleIndex === -1) {
|
||||||
return (
|
return (
|
||||||
<Finish
|
<Finish
|
||||||
isLoading={isEvaluationLoading}
|
isLoading={isEvaluationLoading}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
|
|||||||
|
|
||||||
<div className="w-full flex gap-4">
|
<div className="w-full flex gap-4">
|
||||||
<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">Referral</label>
|
<label className="font-normal text-base text-mti-gray-dim">Referral *</label>
|
||||||
<Select
|
<Select
|
||||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
options={[
|
options={[
|
||||||
@@ -171,7 +171,7 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser,
|
|||||||
</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">Subscription Duration</label>
|
<label className="font-normal text-base text-mti-gray-dim">Subscription Duration *</label>
|
||||||
<Select
|
<Select
|
||||||
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
options={Object.keys(availableDurations).map((value) => ({
|
options={Object.keys(availableDurations).map((value) => ({
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ export default function RegisterIndividual({queryCode, defaultInformation, isLoa
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/** TODO: Add a checkbox to disable code */}
|
|
||||||
<div className="flex flex-col gap-4 w-full items-start">
|
<div className="flex flex-col gap-4 w-full items-start">
|
||||||
<Checkbox isChecked={hasCode} onChange={setHasCode}>
|
<Checkbox isChecked={hasCode} onChange={setHasCode}>
|
||||||
I have a code
|
I have a code
|
||||||
@@ -117,6 +116,7 @@ export default function RegisterIndividual({queryCode, defaultInformation, isLoa
|
|||||||
onChange={(e) => setCode(e)}
|
onChange={(e) => setCode(e)}
|
||||||
placeholder="Enter your registration code (optional)"
|
placeholder="Enter your registration code (optional)"
|
||||||
defaultValue={code}
|
defaultValue={code}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default function PaymentDue({user, hasExpired = false, clientID, reload}:
|
|||||||
if (userGroups.length === 0) return true;
|
if (userGroups.length === 0) return true;
|
||||||
|
|
||||||
const userGroupsAdminTypes = userGroups.map((g) => users?.find((u) => u.id === g.admin)?.type).filter((t) => !!t);
|
const userGroupsAdminTypes = userGroups.map((g) => users?.find((u) => u.id === g.admin)?.type).filter((t) => !!t);
|
||||||
return userGroupsAdminTypes.every((t) => t !== "admin");
|
return userGroupsAdminTypes.every((t) => t !== "corporate");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,429 +1,434 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import { app, storage } from "@/firebase";
|
import {app, storage} from "@/firebase";
|
||||||
import {
|
import {getFirestore, doc, getDoc, updateDoc, getDocs, query, collection, where, documentId} from "firebase/firestore";
|
||||||
getFirestore,
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
doc,
|
import {sessionOptions} from "@/lib/session";
|
||||||
getDoc,
|
|
||||||
updateDoc,
|
|
||||||
getDocs,
|
|
||||||
query,
|
|
||||||
collection,
|
|
||||||
where,
|
|
||||||
documentId,
|
|
||||||
} from "firebase/firestore";
|
|
||||||
import { withIronSessionApiRoute } from "iron-session/next";
|
|
||||||
import { sessionOptions } from "@/lib/session";
|
|
||||||
import ReactPDF from "@react-pdf/renderer";
|
import ReactPDF from "@react-pdf/renderer";
|
||||||
import GroupTestReport from "@/exams/pdf/group.test.report";
|
import GroupTestReport from "@/exams/pdf/group.test.report";
|
||||||
import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
|
import {ref, uploadBytes, getDownloadURL} from "firebase/storage";
|
||||||
import { Stat } from "@/interfaces/user";
|
import {Stat, CorporateUser} from "@/interfaces/user";
|
||||||
import { User } from "@/interfaces/user";
|
import {User, DemographicInformation} from "@/interfaces/user";
|
||||||
import { Module } from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import { ModuleScore, StudentData } from "@/interfaces/module.scores";
|
import {ModuleScore, StudentData} from "@/interfaces/module.scores";
|
||||||
import { SkillExamDetails } from "@/exams/pdf/details/skill.exam";
|
import {SkillExamDetails} from "@/exams/pdf/details/skill.exam";
|
||||||
import { LevelExamDetails } from "@/exams/pdf/details/level.exam";
|
import {LevelExamDetails} from "@/exams/pdf/details/level.exam";
|
||||||
import { calculateBandScore, getLevelScore } from "@/utils/score";
|
import {calculateBandScore, getLevelScore} from "@/utils/score";
|
||||||
import {
|
import {generateQRCode, getRadialProgressPNG, streamToBuffer} from "@/utils/pdf";
|
||||||
generateQRCode,
|
import {Group} from "@/interfaces/user";
|
||||||
getRadialProgressPNG,
|
import moment from "moment-timezone";
|
||||||
streamToBuffer,
|
|
||||||
} from "@/utils/pdf";
|
|
||||||
|
|
||||||
interface GroupScoreSummaryHelper {
|
interface GroupScoreSummaryHelper {
|
||||||
score: [number, number];
|
score: [number, number];
|
||||||
label: string;
|
label: string;
|
||||||
sessions: string[];
|
sessions: string[];
|
||||||
}
|
}
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
|
|
||||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method === "GET") return get(req, res);
|
if (req.method === "GET") return get(req, res);
|
||||||
if (req.method === "POST") return post(req, res);
|
if (req.method === "POST") return post(req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getExamSummary = (score: number) => {
|
const getExamSummary = (score: number) => {
|
||||||
if (score > 0.8) {
|
if (score > 0.8) {
|
||||||
return "Scoring between 81% and 100% on the English exam collectively demonstrates an outstanding level of proficiency in writing, speaking, listening, and reading for this group of students. Mastery of key concepts is evident across all language domains, showcasing not only a high level of skill but also a dedication to excellence. The group is encouraged to continue challenging themselves with advanced material in writing, speaking, listening, and reading to further refine their impressive command of the English language.";
|
return "Scoring between 81% and 100% on the English exam collectively demonstrates an outstanding level of proficiency in writing, speaking, listening, and reading for this group of students. Mastery of key concepts is evident across all language domains, showcasing not only a high level of skill but also a dedication to excellence. The group is encouraged to continue challenging themselves with advanced material in writing, speaking, listening, and reading to further refine their impressive command of the English language.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (score > 0.6) {
|
if (score > 0.6) {
|
||||||
return "The group's average scores between 61% and 80% on the English exam, encompassing writing, speaking, listening, and reading, reflect a commendable level of proficiency. There's evidence of a solid grasp of key concepts collectively, and effective application of skills. Room for refinement and deeper exploration in writing, speaking, listening, and reading remains, presenting an opportunity for the entire group to further their mastery.";
|
return "The group's average scores between 61% and 80% on the English exam, encompassing writing, speaking, listening, and reading, reflect a commendable level of proficiency. There's evidence of a solid grasp of key concepts collectively, and effective application of skills. Room for refinement and deeper exploration in writing, speaking, listening, and reading remains, presenting an opportunity for the entire group to further their mastery.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (score > 0.4) {
|
if (score > 0.4) {
|
||||||
return "Scoring between 41% and 60% on the English exam across writing, speaking, listening, and reading indicates a moderate level of understanding for the group. While there's a commendable grasp of key concepts collectively, refining fundamental skills in writing, speaking, listening, and reading can lead to notable improvement. The group is encouraged to work together with consistent effort and targeted focus on weaker areas.";
|
return "Scoring between 41% and 60% on the English exam across writing, speaking, listening, and reading indicates a moderate level of understanding for the group. While there's a commendable grasp of key concepts collectively, refining fundamental skills in writing, speaking, listening, and reading can lead to notable improvement. The group is encouraged to work together with consistent effort and targeted focus on weaker areas.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (score > 0.2) {
|
if (score > 0.2) {
|
||||||
return "The group's average scores between 21% and 40% on the English exam, encompassing writing, speaking, listening, and reading, show some understanding of key concepts in each domain. However, there's room for improvement in fundamental skills for the entire group. Strengthening writing, speaking, listening, and reading abilities collectively through consistent effort and focused group study will contribute to overall proficiency.";
|
return "The group's average scores between 21% and 40% on the English exam, encompassing writing, speaking, listening, and reading, show some understanding of key concepts in each domain. However, there's room for improvement in fundamental skills for the entire group. Strengthening writing, speaking, listening, and reading abilities collectively through consistent effort and focused group study will contribute to overall proficiency.";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "The average performance of this group of students in English, covering writing, speaking, listening, and reading, indicates a collective need for improvement, with scores falling between 0% and 20%. Across all language domains, there's a significant gap in understanding key concepts. Strengthening fundamental skills in writing, speaking, listening, and reading is crucial for the entire group. Implementing a shared, consistent study routine and seeking group support in each area can contribute to substantial progress.";
|
return "The average performance of this group of students in English, covering writing, speaking, listening, and reading, indicates a collective need for improvement, with scores falling between 0% and 20%. Across all language domains, there's a significant gap in understanding key concepts. Strengthening fundamental skills in writing, speaking, listening, and reading is crucial for the entire group. Implementing a shared, consistent study routine and seeking group support in each area can contribute to substantial progress.";
|
||||||
};
|
};
|
||||||
|
|
||||||
const getLevelSummary = (score: number) => {
|
const getLevelSummary = (score: number) => {
|
||||||
if (score > 0.8) {
|
if (score > 0.8) {
|
||||||
return "Scoring between 81% and 100% on the English exam collectively demonstrates an outstanding level of proficiency for this group of students, showcasing a mastery of key concepts related to vocabulary and grammar. There's evidence of not only a high level of skill but also a dedication to excellence. The group is encouraged to continue challenging themselves with advanced material in vocabulary and grammar to further refine their impressive command of the English language.";
|
return "Scoring between 81% and 100% on the English exam collectively demonstrates an outstanding level of proficiency for this group of students, showcasing a mastery of key concepts related to vocabulary and grammar. There's evidence of not only a high level of skill but also a dedication to excellence. The group is encouraged to continue challenging themselves with advanced material in vocabulary and grammar to further refine their impressive command of the English language.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (score > 0.6) {
|
if (score > 0.6) {
|
||||||
return "The group's average scores between 61% and 80% on the English exam reflect a commendable level of proficiency with solid grasp of key concepts related to vocabulary and grammar. Room for refinement and deeper exploration in these language skills remains, presenting an opportunity for the entire group to further their mastery. Consistent effort in honing nuanced aspects of vocabulary and grammar will contribute to even greater proficiency.";
|
return "The group's average scores between 61% and 80% on the English exam reflect a commendable level of proficiency with solid grasp of key concepts related to vocabulary and grammar. Room for refinement and deeper exploration in these language skills remains, presenting an opportunity for the entire group to further their mastery. Consistent effort in honing nuanced aspects of vocabulary and grammar will contribute to even greater proficiency.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (score > 0.4) {
|
if (score > 0.4) {
|
||||||
return "Scoring between 41% and 60% on the English exam indicates a moderate level of understanding for the group, with commendable grasp of key concepts related to vocabulary and grammar. Refining these fundamental language skills can lead to notable improvement. The group is encouraged to work together with consistent effort and targeted focus on enhancing their vocabulary and grammar.";
|
return "Scoring between 41% and 60% on the English exam indicates a moderate level of understanding for the group, with commendable grasp of key concepts related to vocabulary and grammar. Refining these fundamental language skills can lead to notable improvement. The group is encouraged to work together with consistent effort and targeted focus on enhancing their vocabulary and grammar.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (score > 0.2) {
|
if (score > 0.2) {
|
||||||
return "The group's average scores between 21% and 40% on the English exam show some understanding of key concepts in vocabulary and grammar. However, there's room for improvement in these fundamental language skills for the entire group. Strengthening vocabulary and grammar collectively through consistent effort and focused group study will contribute to overall proficiency.";
|
return "The group's average scores between 21% and 40% on the English exam show some understanding of key concepts in vocabulary and grammar. However, there's room for improvement in these fundamental language skills for the entire group. Strengthening vocabulary and grammar collectively through consistent effort and focused group study will contribute to overall proficiency.";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "The average performance of this group of students in English suggests a collective need for improvement, with scores falling between 0% and 20%. There's a significant gap in understanding key concepts related to vocabulary and grammar. Strengthening fundamental language skills, such as vocabulary and grammar, is crucial for the entire group. Implementing a shared, consistent study routine and seeking group support in these areas can contribute to substantial progress.";
|
return "The average performance of this group of students in English suggests a collective need for improvement, with scores falling between 0% and 20%. There's a significant gap in understanding key concepts related to vocabulary and grammar. Strengthening fundamental language skills, such as vocabulary and grammar, is crucial for the entire group. Implementing a shared, consistent study routine and seeking group support in these areas can contribute to substantial progress.";
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPerformanceSummary = (module: Module, score: number) => {
|
const getPerformanceSummary = (module: Module, score: number) => {
|
||||||
if (module === "level") return getLevelSummary(score);
|
if (module === "level") return getLevelSummary(score);
|
||||||
return getExamSummary(score);
|
return getExamSummary(score);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getScoreAndTotal = (stats: Stat[]) => {
|
const getScoreAndTotal = (stats: Stat[]) => {
|
||||||
return stats.reduce(
|
return stats.reduce(
|
||||||
(acc, { score }) => {
|
(acc, {score}) => {
|
||||||
return {
|
return {
|
||||||
...acc,
|
...acc,
|
||||||
correct: acc.correct + score.correct,
|
correct: acc.correct + score.correct,
|
||||||
total: acc.total + score.total,
|
total: acc.total + score.total,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
{ correct: 0, total: 0 }
|
{correct: 0, total: 0},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getLevelScoreForUserExams = (bandScore: number) => {
|
const getLevelScoreForUserExams = (bandScore: number) => {
|
||||||
const [level] = getLevelScore(bandScore);
|
const [level] = getLevelScore(bandScore);
|
||||||
return level;
|
return level;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
// verify if it's a logged user that is trying to export
|
// verify if it's a logged user that is trying to export
|
||||||
if (req.session.user) {
|
if (req.session.user) {
|
||||||
const { id } = req.query as { id: string };
|
const {id} = req.query as {id: string};
|
||||||
|
|
||||||
const docSnap = await getDoc(doc(db, "assignments", id));
|
const docSnap = await getDoc(doc(db, "assignments", id));
|
||||||
const data = docSnap.data() as {
|
const data = docSnap.data() as {
|
||||||
assigner: string;
|
assigner: string;
|
||||||
assignees: string[];
|
assignees: string[];
|
||||||
results: any;
|
results: any;
|
||||||
exams: { module: Module }[];
|
exams: {module: Module}[];
|
||||||
startDate: string;
|
startDate: string;
|
||||||
pdf?: string;
|
pdf?: string;
|
||||||
};
|
};
|
||||||
if (!data) {
|
if (!data) {
|
||||||
res.status(400).end();
|
res.status(400).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.assigner !== req.session.user.id) {
|
if (data.assigner !== req.session.user.id) {
|
||||||
res.status(401).json({ ok: false });
|
res.status(401).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data.pdf) {
|
if (data.pdf) {
|
||||||
// if it does, return the pdf url
|
// if it does, return the pdf url
|
||||||
const fileRef = ref(storage, data.pdf);
|
const fileRef = ref(storage, data.pdf);
|
||||||
const url = await getDownloadURL(fileRef);
|
const url = await getDownloadURL(fileRef);
|
||||||
|
|
||||||
res.status(200).end(url);
|
res.status(200).end(url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const docUser = await getDoc(doc(db, "users", req.session.user.id));
|
const docUser = await getDoc(doc(db, "users", req.session.user.id));
|
||||||
if (docUser.exists()) {
|
if (docUser.exists()) {
|
||||||
// we'll need the user in order to get the user data (name, email, focus, etc);
|
// we'll need the user in order to get the user data (name, email, focus, etc);
|
||||||
const user = docUser.data() as User;
|
const user = docUser.data() as User;
|
||||||
|
|
||||||
// generate the QR code for the report
|
// generate the QR code for the report
|
||||||
const qrcode = await generateQRCode(
|
const qrcode = await generateQRCode((req.headers.origin || "") + req.url);
|
||||||
(req.headers.origin || "") + req.url
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!qrcode) {
|
if (!qrcode) {
|
||||||
res.status(500).json({ ok: false });
|
res.status(500).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const flattenResults = data.results.reduce(
|
const flattenResults = data.results.reduce((accm: Stat[], entry: any) => {
|
||||||
(accm: Stat[], entry: any) => {
|
const stats = entry.stats as Stat[];
|
||||||
const stats = entry.stats as Stat[];
|
return [...accm, ...stats];
|
||||||
return [...accm, ...stats];
|
}, []) as Stat[];
|
||||||
},
|
|
||||||
[]
|
|
||||||
) as Stat[];
|
|
||||||
|
|
||||||
const docsSnap = await getDocs(
|
const docsSnap = await getDocs(query(collection(db, "users"), where(documentId(), "in", data.assignees)));
|
||||||
query(
|
const users = docsSnap.docs.map((d) => ({
|
||||||
collection(db, "users"),
|
...d.data(),
|
||||||
where(documentId(), "in", data.assignees)
|
id: d.id,
|
||||||
)
|
})) as User[];
|
||||||
);
|
|
||||||
const users = docsSnap.docs.map((d) => ({
|
|
||||||
...d.data(),
|
|
||||||
id: d.id,
|
|
||||||
})) as User[];
|
|
||||||
|
|
||||||
const flattenResultsWithGrade = flattenResults.map((e) => {
|
const flattenResultsWithGrade = flattenResults.map((e) => {
|
||||||
const focus = users.find((u) => u.id === e.user)?.focus || "academic";
|
const focus = users.find((u) => u.id === e.user)?.focus || "academic";
|
||||||
const bandScore = calculateBandScore(
|
const bandScore = calculateBandScore(e.score.correct, e.score.total, e.module, focus);
|
||||||
e.score.correct,
|
|
||||||
e.score.total,
|
|
||||||
e.module,
|
|
||||||
focus
|
|
||||||
);
|
|
||||||
|
|
||||||
return { ...e, bandScore };
|
return {...e, bandScore};
|
||||||
});
|
});
|
||||||
|
|
||||||
const moduleResults = data.exams.map(({ module }) => {
|
const moduleResults = data.exams.map(({module}) => {
|
||||||
const moduleResults = flattenResultsWithGrade.filter(
|
const moduleResults = flattenResultsWithGrade.filter((e) => e.module === module);
|
||||||
(e) => e.module === module
|
|
||||||
);
|
|
||||||
|
|
||||||
const baseBandScore =
|
const baseBandScore = moduleResults.reduce((accm, curr) => accm + curr.bandScore, 0) / moduleResults.length;
|
||||||
moduleResults.reduce((accm, curr) => accm + curr.bandScore, 0) /
|
const bandScore = isNaN(baseBandScore) ? 0 : baseBandScore;
|
||||||
moduleResults.length;
|
const {correct, total} = getScoreAndTotal(moduleResults);
|
||||||
const bandScore = isNaN(baseBandScore) ? 0 : baseBandScore;
|
const png = getRadialProgressPNG("azul", correct, total);
|
||||||
const { correct, total } = getScoreAndTotal(moduleResults);
|
|
||||||
const png = getRadialProgressPNG("azul", correct, total);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bandScore,
|
bandScore,
|
||||||
png,
|
png,
|
||||||
module: module[0].toUpperCase() + module.substring(1),
|
module: module[0].toUpperCase() + module.substring(1),
|
||||||
score: bandScore,
|
score: bandScore,
|
||||||
total,
|
total,
|
||||||
code: module,
|
code: module,
|
||||||
};
|
};
|
||||||
}) as ModuleScore[];
|
}) as ModuleScore[];
|
||||||
|
|
||||||
const { correct: overallCorrect, total: overallTotal } =
|
const {correct: overallCorrect, total: overallTotal} = getScoreAndTotal(flattenResults);
|
||||||
getScoreAndTotal(flattenResults);
|
const baseOverallResult = overallCorrect / overallTotal;
|
||||||
const baseOverallResult = overallCorrect / overallTotal;
|
const overallResult = isNaN(baseOverallResult) ? 0 : baseOverallResult;
|
||||||
const overallResult = isNaN(baseOverallResult) ? 0 : baseOverallResult;
|
|
||||||
|
|
||||||
const overallPNG = getRadialProgressPNG(
|
const overallPNG = getRadialProgressPNG("laranja", overallCorrect, overallTotal);
|
||||||
"laranja",
|
// generate the overall detail report
|
||||||
overallCorrect,
|
const overallDetail = {
|
||||||
overallTotal
|
module: "Overall",
|
||||||
);
|
score: overallCorrect,
|
||||||
// generate the overall detail report
|
total: overallTotal,
|
||||||
const overallDetail = {
|
png: overallPNG,
|
||||||
module: "Overall",
|
} as ModuleScore;
|
||||||
score: overallCorrect,
|
|
||||||
total: overallTotal,
|
|
||||||
png: overallPNG,
|
|
||||||
} as ModuleScore;
|
|
||||||
|
|
||||||
const testDetails = [overallDetail, ...moduleResults];
|
const testDetails = [overallDetail, ...moduleResults];
|
||||||
// generate the performance summary based on the overall result
|
// generate the performance summary based on the overall result
|
||||||
const baseStat = data.exams[0];
|
const baseStat = data.exams[0];
|
||||||
const performanceSummary = getPerformanceSummary(
|
const performanceSummary = getPerformanceSummary(
|
||||||
// from what I noticed, exams is either an array with the level module
|
// from what I noticed, exams is either an array with the level module
|
||||||
// or X modules, either way
|
// or X modules, either way
|
||||||
// as long as I verify the first entry I should be fine
|
// as long as I verify the first entry I should be fine
|
||||||
baseStat.module,
|
baseStat.module,
|
||||||
overallResult
|
overallResult,
|
||||||
);
|
);
|
||||||
|
|
||||||
const showLevel = baseStat.module === "level";
|
const showLevel = baseStat.module === "level";
|
||||||
|
|
||||||
// level exams have a different report structure than the skill exams
|
// level exams have a different report structure than the skill exams
|
||||||
const getCustomData = () => {
|
const getCustomData = () => {
|
||||||
if (showLevel) {
|
if (showLevel) {
|
||||||
return {
|
return {
|
||||||
title: "GROUP ENGLISH LEVEL TEST RESULT REPORT ",
|
title: "GROUP ENGLISH LEVEL TEST RESULT REPORT ",
|
||||||
details: (
|
details: <LevelExamDetails detail={overallDetail} title="Group Average CEFR" />,
|
||||||
<LevelExamDetails
|
};
|
||||||
detail={overallDetail}
|
}
|
||||||
title="Group Average CEFR"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: "GROUP ENGLISH SKILLS TEST RESULT REPORT",
|
title: "GROUP ENGLISH SKILLS TEST RESULT REPORT",
|
||||||
details: <SkillExamDetails testDetails={testDetails} />,
|
details: <SkillExamDetails testDetails={testDetails} />,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const { title, details } = getCustomData();
|
const {title, details} = getCustomData();
|
||||||
|
|
||||||
const numberOfStudents = data.assignees.length;
|
const numberOfStudents = data.assignees.length;
|
||||||
|
|
||||||
const getStudentsData = async (): Promise<StudentData[]> => {
|
const getStudentsData = async (): Promise<StudentData[]> => {
|
||||||
return data.assignees.map((id) => {
|
return data.assignees.map((id) => {
|
||||||
const user = users.find((u) => u.id === id);
|
const user = users.find((u) => u.id === id);
|
||||||
const exams = flattenResultsWithGrade.filter((e) => e.user === id);
|
const exams = flattenResultsWithGrade.filter((e) => e.user === id);
|
||||||
const date =
|
const date =
|
||||||
exams.length === 0
|
exams.length === 0
|
||||||
? "N/A"
|
? "N/A"
|
||||||
: new Date(exams[0].date).toLocaleDateString(undefined, {
|
: new Date(exams[0].date).toLocaleDateString(undefined, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "numeric",
|
month: "numeric",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
});
|
});
|
||||||
|
|
||||||
const bandScore =
|
const bandScore = exams.length === 0 ? 0 : exams.reduce((accm, curr) => accm + curr.bandScore, 0) / exams.length;
|
||||||
exams.length === 0
|
const {correct, total} = getScoreAndTotal(exams);
|
||||||
? 0
|
|
||||||
: exams.reduce((accm, curr) => accm + curr.bandScore, 0) /
|
|
||||||
exams.length;
|
|
||||||
const { correct, total } = getScoreAndTotal(exams);
|
|
||||||
|
|
||||||
const result = exams.length === 0 ? "N/A" : `${correct}/${total}`;
|
const result = exams.length === 0 ? "N/A" : `${correct}/${total}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: user?.name || "N/A",
|
name: user?.name || "N/A",
|
||||||
email: user?.email || "N/A",
|
email: user?.email || "N/A",
|
||||||
gender: user?.demographicInformation?.gender || "N/A",
|
gender: user?.demographicInformation?.gender || "N/A",
|
||||||
date,
|
date,
|
||||||
result,
|
result,
|
||||||
level: showLevel
|
level: showLevel ? getLevelScoreForUserExams(bandScore) : undefined,
|
||||||
? getLevelScoreForUserExams(bandScore)
|
bandScore,
|
||||||
: undefined,
|
};
|
||||||
bandScore,
|
});
|
||||||
};
|
};
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const studentsData = await getStudentsData();
|
const studentsData = await getStudentsData();
|
||||||
|
|
||||||
const getGroupScoreSummary = () => {
|
const getGroupScoreSummary = () => {
|
||||||
const resultHelper = studentsData.reduce(
|
const resultHelper = studentsData.reduce((accm: GroupScoreSummaryHelper[], curr) => {
|
||||||
(accm: GroupScoreSummaryHelper[], curr) => {
|
const {bandScore, id} = curr;
|
||||||
const { bandScore, id } = curr;
|
|
||||||
|
|
||||||
const flooredScore = Math.floor(bandScore);
|
const flooredScore = Math.floor(bandScore);
|
||||||
|
|
||||||
const hasMatch = accm.find((a) => a.score.includes(flooredScore));
|
const hasMatch = accm.find((a) => a.score.includes(flooredScore));
|
||||||
if (hasMatch) {
|
if (hasMatch) {
|
||||||
return accm.map((a) => {
|
return accm.map((a) => {
|
||||||
if (a.score.includes(flooredScore)) {
|
if (a.score.includes(flooredScore)) {
|
||||||
return {
|
return {
|
||||||
...a,
|
...a,
|
||||||
sessions: [...a.sessions, id],
|
sessions: [...a.sessions, id],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return a;
|
return a;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...accm,
|
...accm,
|
||||||
{
|
{
|
||||||
score: [flooredScore, flooredScore + 0.5],
|
score: [flooredScore, flooredScore + 0.5],
|
||||||
label: `${flooredScore} - ${flooredScore + 0.5}`,
|
label: `${flooredScore} - ${flooredScore + 0.5}`,
|
||||||
sessions: [id],
|
sessions: [id],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
}, []) as GroupScoreSummaryHelper[];
|
||||||
[]
|
|
||||||
) as GroupScoreSummaryHelper[];
|
|
||||||
|
|
||||||
const result = resultHelper.map(({ score, label, sessions }) => {
|
const result = resultHelper.map(({score, label, sessions}) => {
|
||||||
const finalLabel = showLevel ? getLevelScore(score[0])[1] : label;
|
const finalLabel = showLevel ? getLevelScore(score[0])[1] : label;
|
||||||
return {
|
return {
|
||||||
label: finalLabel,
|
label: finalLabel,
|
||||||
percent: Math.floor((sessions.length / numberOfStudents) * 100),
|
percent: Math.floor((sessions.length / numberOfStudents) * 100),
|
||||||
description: `No. Candidates ${sessions.length} of ${numberOfStudents}`,
|
description: `No. Candidates ${sessions.length} of ${numberOfStudents}`,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const groupScoreSummary = getGroupScoreSummary();
|
const getInstitution = async () => {
|
||||||
|
try {
|
||||||
|
// due to database inconsistencies, I'll be overprotective here
|
||||||
|
const assignerUserSnap = await getDoc(doc(db, "users", data.assigner));
|
||||||
|
if (assignerUserSnap.exists()) {
|
||||||
|
// we'll need the user in order to get the user data (name, email, focus, etc);
|
||||||
|
const assignerUser = assignerUserSnap.data() as User;
|
||||||
|
|
||||||
const pdfStream = await ReactPDF.renderToStream(
|
if (assignerUser.type === "teacher") {
|
||||||
<GroupTestReport
|
// also search for groups where this user belongs
|
||||||
title={title}
|
const queryGroups = query(collection(db, "groups"), where("participants", "array-contains", assignerUser.id));
|
||||||
date={new Date(data.startDate).toLocaleString()}
|
const groupSnapshot = await getDocs(queryGroups);
|
||||||
name={user.name}
|
|
||||||
email={user.email}
|
|
||||||
id={user.id}
|
|
||||||
gender={user.demographicInformation?.gender}
|
|
||||||
summary={performanceSummary}
|
|
||||||
renderDetails={details}
|
|
||||||
logo={"public/logo_title.png"}
|
|
||||||
qrcode={qrcode}
|
|
||||||
numberOfStudents={numberOfStudents}
|
|
||||||
institution="TODO: PLACEHOLDER"
|
|
||||||
studentsData={studentsData}
|
|
||||||
showLevel={showLevel}
|
|
||||||
summaryPNG={overallPNG}
|
|
||||||
summaryScore={`${(overallResult * 100).toFixed(0)}%`}
|
|
||||||
groupScoreSummary={groupScoreSummary}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// generate the file ref for storage
|
const groups = groupSnapshot.docs.map((doc) => ({
|
||||||
const fileName = `${Date.now().toString()}.pdf`;
|
id: doc.id,
|
||||||
const refName = `assignment_report/${fileName}`;
|
...doc.data(),
|
||||||
const fileRef = ref(storage, refName);
|
})) as Group[];
|
||||||
|
|
||||||
// upload the pdf to storage
|
if (groups.length > 0) {
|
||||||
const pdfBuffer = await streamToBuffer(pdfStream);
|
const adminQuery = query(
|
||||||
const snapshot = await uploadBytes(fileRef, pdfBuffer, {
|
collection(db, "users"),
|
||||||
contentType: "application/pdf",
|
where(
|
||||||
});
|
documentId(),
|
||||||
|
"in",
|
||||||
|
groups.map((g) => g.admin),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const adminUsersSnap = await getDocs(adminQuery);
|
||||||
|
|
||||||
// update the stats entries with the pdf url to prevent duplication
|
const admins = adminUsersSnap.docs.map((doc) => ({
|
||||||
await updateDoc(docSnap.ref, {
|
id: doc.id,
|
||||||
pdf: refName,
|
...doc.data(),
|
||||||
});
|
})) as CorporateUser[];
|
||||||
const url = await getDownloadURL(fileRef);
|
|
||||||
res.status(200).end(url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(401).json({ ok: false });
|
const adminData = admins.find((a) => a.corporateInformation?.companyInformation?.name);
|
||||||
return;
|
if (adminData) {
|
||||||
} catch (err) {
|
return adminData.corporateInformation.companyInformation.name;
|
||||||
console.error(err);
|
}
|
||||||
res.status(500).json({ ok: false });
|
}
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
}
|
if (assignerUser.type === "corporate" && assignerUser.corporateInformation?.companyInformation?.name) {
|
||||||
|
return assignerUser.corporateInformation.companyInformation.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const institution = await getInstitution();
|
||||||
|
const groupScoreSummary = getGroupScoreSummary();
|
||||||
|
const demographicInformation = user.demographicInformation as DemographicInformation;
|
||||||
|
const pdfStream = await ReactPDF.renderToStream(
|
||||||
|
<GroupTestReport
|
||||||
|
title={title}
|
||||||
|
date={moment(data.startDate)
|
||||||
|
.tz(user.demographicInformation?.timezone || "UTC")
|
||||||
|
.format("ll HH:mm:ss")}
|
||||||
|
name={user.name}
|
||||||
|
email={user.email}
|
||||||
|
id={user.id}
|
||||||
|
gender={demographicInformation?.gender}
|
||||||
|
summary={performanceSummary}
|
||||||
|
renderDetails={details}
|
||||||
|
logo={"public/logo_title.png"}
|
||||||
|
qrcode={qrcode}
|
||||||
|
numberOfStudents={numberOfStudents}
|
||||||
|
institution={institution}
|
||||||
|
studentsData={studentsData}
|
||||||
|
showLevel={showLevel}
|
||||||
|
summaryPNG={overallPNG}
|
||||||
|
summaryScore={`${(overallResult * 100).toFixed(0)}%`}
|
||||||
|
groupScoreSummary={groupScoreSummary}
|
||||||
|
passportId={demographicInformation?.passport_id || ""}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// generate the file ref for storage
|
||||||
|
const fileName = `${Date.now().toString()}.pdf`;
|
||||||
|
const refName = `assignment_report/${fileName}`;
|
||||||
|
const fileRef = ref(storage, refName);
|
||||||
|
|
||||||
|
// upload the pdf to storage
|
||||||
|
const pdfBuffer = await streamToBuffer(pdfStream);
|
||||||
|
const snapshot = await uploadBytes(fileRef, pdfBuffer, {
|
||||||
|
contentType: "application/pdf",
|
||||||
|
});
|
||||||
|
|
||||||
|
// update the stats entries with the pdf url to prevent duplication
|
||||||
|
await updateDoc(docSnap.ref, {
|
||||||
|
pdf: refName,
|
||||||
|
});
|
||||||
|
const url = await getDownloadURL(fileRef);
|
||||||
|
res.status(200).end(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(401).json({ok: false});
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ok: false});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.session.user) {
|
if (req.session.user) {
|
||||||
const { id } = req.query as { id: string };
|
const {id} = req.query as {id: string};
|
||||||
|
|
||||||
const docSnap = await getDoc(doc(db, "assignments", id));
|
const docSnap = await getDoc(doc(db, "assignments", id));
|
||||||
const data = docSnap.data();
|
const data = docSnap.data();
|
||||||
if (!data) {
|
if (!data) {
|
||||||
res.status(400).end();
|
res.status(400).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.assigner !== req.session.user.id) {
|
if (data.assigner !== req.session.user.id) {
|
||||||
res.status(401).json({ ok: false });
|
res.status(401).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.pdf) {
|
if (data.pdf) {
|
||||||
const fileRef = ref(storage, data.pdf);
|
const fileRef = ref(storage, data.pdf);
|
||||||
const url = await getDownloadURL(fileRef);
|
const url = await getDownloadURL(fileRef);
|
||||||
return res.redirect(url);
|
return res.redirect(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(404).end();
|
res.status(404).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(401).json({ ok: false });
|
res.status(401).json({ok: false});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
import {app} from "@/firebase";
|
import {app} from "@/firebase";
|
||||||
import {getFirestore, collection, getDocs, setDoc, doc} from "firebase/firestore";
|
import {getFirestore, collection, getDocs, setDoc, doc, query, where} 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 {Group} from "@/interfaces/user";
|
import {Group} from "@/interfaces/user";
|
||||||
@@ -22,25 +22,19 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {admin} = req.query as {admin: string};
|
const {admin, participant} = req.query as {admin: string; participant: string};
|
||||||
const snapshot = await getDocs(collection(db, "groups"));
|
|
||||||
|
|
||||||
const groups: Group[] = snapshot.docs.map((doc) => ({
|
const queryConstraints = [
|
||||||
|
...(admin ? [where("admin", "==", admin)] : []),
|
||||||
|
...(participant ? [where("participants", "array-contains", participant)] : []),
|
||||||
|
];
|
||||||
|
const snapshot = await getDocs(queryConstraints.length > 0 ? query(collection(db, "groups"), ...queryConstraints) : collection(db, "groups"));
|
||||||
|
const groups = snapshot.docs.map((doc) => ({
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
...doc.data(),
|
...doc.data(),
|
||||||
})) as Group[];
|
})) as Group[];
|
||||||
|
|
||||||
if (admin) {
|
res.status(200).json(groups);
|
||||||
res.status(200).json(groups.filter((x) => x.admin === admin));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).json(
|
|
||||||
snapshot.docs.map((doc) => ({
|
|
||||||
id: doc.id,
|
|
||||||
...doc.data(),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { sessionOptions } from "@/lib/session";
|
|||||||
import ReactPDF from "@react-pdf/renderer";
|
import ReactPDF from "@react-pdf/renderer";
|
||||||
import TestReport from "@/exams/pdf/test.report";
|
import TestReport from "@/exams/pdf/test.report";
|
||||||
import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
|
import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
|
||||||
import { User } from "@/interfaces/user";
|
import { DemographicInformation, User } from "@/interfaces/user";
|
||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import { ModuleScore } from "@/interfaces/module.scores";
|
import { ModuleScore } from "@/interfaces/module.scores";
|
||||||
import { SkillExamDetails } from "@/exams/pdf/details/skill.exam";
|
import { SkillExamDetails } from "@/exams/pdf/details/skill.exam";
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
getRadialProgressPNG,
|
getRadialProgressPNG,
|
||||||
streamToBuffer,
|
streamToBuffer,
|
||||||
} from "@/utils/pdf";
|
} from "@/utils/pdf";
|
||||||
|
import moment from "moment-timezone";
|
||||||
|
|
||||||
const db = getFirestore(app);
|
const db = getFirestore(app);
|
||||||
|
|
||||||
@@ -263,12 +264,14 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
);
|
);
|
||||||
const overallResult = overallScore / overallTotal;
|
const overallResult = overallScore / overallTotal;
|
||||||
|
|
||||||
|
const overallPNG = getRadialProgressPNG("laranja", overallScore, overallTotal);
|
||||||
|
|
||||||
// generate the overall detail report
|
// generate the overall detail report
|
||||||
const overallDetail = {
|
const overallDetail = {
|
||||||
module: "Overall",
|
module: "Overall",
|
||||||
score: overallScore,
|
score: overallScore,
|
||||||
total: overallTotal,
|
total: overallTotal,
|
||||||
png: getRadialProgressPNG("laranja", overallScore, overallTotal),
|
png: overallPNG,
|
||||||
} as ModuleScore;
|
} as ModuleScore;
|
||||||
const testDetails = [overallDetail, ...finalResults];
|
const testDetails = [overallDetail, ...finalResults];
|
||||||
|
|
||||||
@@ -301,19 +304,24 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { title, details } = getCustomData();
|
const { title, details } = getCustomData();
|
||||||
|
|
||||||
|
const demographicInformation = user.demographicInformation as DemographicInformation;
|
||||||
const pdfStream = await ReactPDF.renderToStream(
|
const pdfStream = await ReactPDF.renderToStream(
|
||||||
<TestReport
|
<TestReport
|
||||||
title={title}
|
title={title}
|
||||||
date={new Date(stat.date).toLocaleString()}
|
date={moment(stat.date).tz(user.demographicInformation?.timezone || 'UTC').format('ll HH:mm:ss')}
|
||||||
name={user.name}
|
name={user.name}
|
||||||
email={user.email}
|
email={user.email}
|
||||||
id={user.id}
|
id={user.id}
|
||||||
gender={user.demographicInformation?.gender}
|
gender={demographicInformation?.gender}
|
||||||
summary={performanceSummary}
|
summary={performanceSummary}
|
||||||
testDetails={testDetails}
|
testDetails={testDetails}
|
||||||
renderDetails={details}
|
renderDetails={details}
|
||||||
logo={"public/logo_title.png"}
|
logo={"public/logo_title.png"}
|
||||||
qrcode={qrcode}
|
qrcode={qrcode}
|
||||||
|
summaryPNG={overallPNG}
|
||||||
|
summaryScore={`${(overallResult * 100).toFixed(0)}%`}
|
||||||
|
passportId={demographicInformation?.passport_id || ""}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
res.json({ok: true});
|
||||||
|
|
||||||
|
await auth.deleteUser(id);
|
||||||
|
await deleteDoc(doc(db, "users", id));
|
||||||
const userCodeDocs = await getDocs(query(collection(db, "codes"), where("userId", "==", id)));
|
const userCodeDocs = await getDocs(query(collection(db, "codes"), where("userId", "==", id)));
|
||||||
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)));
|
||||||
const userGroupAdminDocs = await getDocs(query(collection(db, "groups"), where("admin", "==", id)));
|
const userGroupAdminDocs = await getDocs(query(collection(db, "groups"), where("admin", "==", id)));
|
||||||
@@ -62,11 +66,6 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
async (x) => await setDoc(x.ref, {participants: x.data().participants.filter((y: string) => y !== id)}, {merge: true}),
|
async (x) => await setDoc(x.ref, {participants: x.data().participants.filter((y: string) => y !== id)}, {merge: true}),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await auth.deleteUser(id);
|
|
||||||
await deleteDoc(doc(db, "users", id));
|
|
||||||
|
|
||||||
res.json({ok: true});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function get(req: NextApiRequest, res: NextApiResponse) {
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default function Generation() {
|
|||||||
value={module}
|
value={module}
|
||||||
onChange={setModule}
|
onChange={setModule}
|
||||||
className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
|
className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
|
||||||
{[...MODULE_ARRAY, "level"].map((x) => (
|
{[...MODULE_ARRAY].map((x) => (
|
||||||
<RadioGroup.Option value={x} key={x}>
|
<RadioGroup.Option value={x} key={x}>
|
||||||
{({checked}) => (
|
{({checked}) => (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import {toast, ToastContainer} from "react-toastify";
|
import {toast, ToastContainer} from "react-toastify";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {FormEvent, useState} from "react";
|
import {FormEvent, useEffect, useState} from "react";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
import {Divider} from "primereact/divider";
|
import {Divider} from "primereact/divider";
|
||||||
@@ -13,9 +13,38 @@ import Input from "@/components/Low/Input";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import EmailVerification from "./(auth)/EmailVerification";
|
import EmailVerification from "./(auth)/EmailVerification";
|
||||||
|
import {withIronSessionSsr} from "iron-session/next";
|
||||||
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
|
||||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/g);
|
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/g);
|
||||||
|
|
||||||
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
|
const user = req.session.user;
|
||||||
|
|
||||||
|
const envVariables: {[key: string]: string} = {};
|
||||||
|
Object.keys(process.env)
|
||||||
|
.filter((x) => x.startsWith("NEXT_PUBLIC"))
|
||||||
|
.forEach((x: string) => {
|
||||||
|
envVariables[x] = process.env[x]!;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user && user.isVerified) {
|
||||||
|
res.setHeader("location", "/");
|
||||||
|
res.statusCode = 302;
|
||||||
|
res.end();
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
user: null,
|
||||||
|
envVariables,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {user: null, envVariables},
|
||||||
|
};
|
||||||
|
}, sessionOptions);
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -29,6 +58,10 @@ export default function Login() {
|
|||||||
redirectIfFound: true,
|
redirectIfFound: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && user.isVerified) router.push("/");
|
||||||
|
}, [router, user]);
|
||||||
|
|
||||||
const forgotPassword = () => {
|
const forgotPassword = () => {
|
||||||
if (!email || email.length < 0 || !EMAIL_REGEX.test(email)) {
|
if (!email || email.length < 0 || !EMAIL_REGEX.test(email)) {
|
||||||
toast.error("Please enter your e-mail to reset your password!", {toastId: "forgot-invalid-email"});
|
toast.error("Please enter your e-mail to reset your password!", {toastId: "forgot-invalid-email"});
|
||||||
|
|||||||
@@ -371,7 +371,7 @@ export default function PaymentRecord() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (reason.response.status === 403) {
|
if (reason.response.status === 403) {
|
||||||
toast.error("You do not have permission to delete this exam!");
|
toast.error("You do not have permission to delete an approved payment record!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -785,7 +785,7 @@ export default function PaymentRecord() {
|
|||||||
<div className="w-full flex flex-end justify-between p-2">
|
<div className="w-full flex flex-end justify-between p-2">
|
||||||
<h1 className="text-2xl font-semibold">Payment Record</h1>
|
<h1 className="text-2xl font-semibold">Payment Record</h1>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
{(user.type === "developer" || user.type === "admin" || user.type === 'agent' || user.type === 'corporate') && (
|
{(user.type === "developer" || user.type === "admin" || user.type === "agent" || user.type === "corporate") && (
|
||||||
<Button className="max-w-[200px]" variant="outline">
|
<Button className="max-w-[200px]" variant="outline">
|
||||||
<CSVLink data={csvRows} headers={csvColumns} filename="payment-records.csv">
|
<CSVLink data={csvRows} headers={csvColumns} filename="payment-records.csv">
|
||||||
Download CSV
|
Download CSV
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
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 {ChangeEvent, useEffect, useRef, useState} from "react";
|
import {ChangeEvent, ReactNode, useEffect, useRef, useState} from "react";
|
||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
import {toast, ToastContainer} from "react-toastify";
|
import {toast, ToastContainer} from "react-toastify";
|
||||||
import Layout from "@/components/High/Layout";
|
import Layout from "@/components/High/Layout";
|
||||||
@@ -11,17 +11,20 @@ import Button from "@/components/Low/Button";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {ErrorMessage} from "@/constants/errors";
|
import {ErrorMessage} from "@/constants/errors";
|
||||||
import {RadioGroup} from "@headlessui/react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "@/interfaces/user";
|
import {CorporateUser, EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "@/interfaces/user";
|
||||||
import CountrySelect from "@/components/Low/CountrySelect";
|
import CountrySelect from "@/components/Low/CountrySelect";
|
||||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {BsCamera, BsCameraFill} from "react-icons/bs";
|
import {BsCamera} from "react-icons/bs";
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import {convertBase64} from "@/utils";
|
||||||
|
import {Divider} from "primereact/divider";
|
||||||
|
import GenderInput from "@/components/High/GenderInput";
|
||||||
|
import EmploymentStatusInput from "@/components/High/EmploymentStatusInput";
|
||||||
|
import TimezoneSelect from "@/components/Low/TImezoneSelect";
|
||||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
|
|
||||||
@@ -74,11 +77,12 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
);
|
);
|
||||||
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 [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
|
const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
|
||||||
|
const [corporateInformation, setCorporateInformation] = useState(user.type === "corporate" ? user.corporateInformation : undefined);
|
||||||
const [companyName, setCompanyName] = useState<string | undefined>(user.type === "agent" ? user.agentInformation?.companyName : undefined);
|
const [companyName, setCompanyName] = useState<string | undefined>(user.type === "agent" ? user.agentInformation?.companyName : undefined);
|
||||||
const [commercialRegistration, setCommercialRegistration] = useState<string | undefined>(
|
const [commercialRegistration, setCommercialRegistration] = useState<string | undefined>(
|
||||||
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
|
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
|
||||||
);
|
);
|
||||||
|
const [timezone, setTimezone] = useState<string>(user.demographicInformation?.timezone || "UTC");
|
||||||
const {groups} = useGroups();
|
const {groups} = useGroups();
|
||||||
const {users} = useUsers();
|
const {users} = useUsers();
|
||||||
|
|
||||||
@@ -92,19 +96,6 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light";
|
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light";
|
||||||
};
|
};
|
||||||
|
|
||||||
const convertBase64 = (file: File) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const fileReader = new FileReader();
|
|
||||||
fileReader.readAsDataURL(file);
|
|
||||||
fileReader.onload = () => {
|
|
||||||
resolve(fileReader.result);
|
|
||||||
};
|
|
||||||
fileReader.onerror = (error) => {
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadProfilePicture = async (event: ChangeEvent<HTMLInputElement>) => {
|
const uploadProfilePicture = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
if (event.target.files && event.target.files[0]) {
|
if (event.target.files && event.target.files[0]) {
|
||||||
const picture = event.target.files[0];
|
const picture = event.target.files[0];
|
||||||
@@ -154,7 +145,9 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
position: user?.type === "corporate" ? position : undefined,
|
position: user?.type === "corporate" ? position : undefined,
|
||||||
gender,
|
gender,
|
||||||
passport_id,
|
passport_id,
|
||||||
|
timezone,
|
||||||
},
|
},
|
||||||
|
...(user.type === "corporate" ? {corporateInformation} : {}),
|
||||||
});
|
});
|
||||||
if (request.status === 200) {
|
if (request.status === 200) {
|
||||||
toast.success("Your profile has been updated!");
|
toast.success("Your profile has been updated!");
|
||||||
@@ -167,6 +160,100 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DoubleColumnRow = ({children}: {children: ReactNode}) => <div className="flex flex-col md:flex-row gap-8 w-full">{children}</div>;
|
||||||
|
|
||||||
|
const PasswordInput = () => (
|
||||||
|
<DoubleColumnRow>
|
||||||
|
<Input
|
||||||
|
label="Current Password"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
onChange={(e) => setPassword(e)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="New Password"
|
||||||
|
type="password"
|
||||||
|
name="newPassword"
|
||||||
|
onChange={(e) => setNewPassword(e)}
|
||||||
|
placeholder="Enter your new password (optional)"
|
||||||
|
/>
|
||||||
|
</DoubleColumnRow>
|
||||||
|
);
|
||||||
|
|
||||||
|
const NameInput = () => (
|
||||||
|
<Input label="Name" type="text" name="name" onChange={(e) => setName(e)} placeholder="Enter your name" defaultValue={name} required />
|
||||||
|
);
|
||||||
|
|
||||||
|
const AgentInformationInput = () => (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
|
||||||
|
<Input
|
||||||
|
label="Corporate Name"
|
||||||
|
type="text"
|
||||||
|
name="companyName"
|
||||||
|
onChange={() => null}
|
||||||
|
placeholder="Enter corporate name"
|
||||||
|
defaultValue={companyName}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Commercial Registration"
|
||||||
|
type="text"
|
||||||
|
name="commercialRegistration"
|
||||||
|
onChange={() => null}
|
||||||
|
placeholder="Enter commercial registration"
|
||||||
|
defaultValue={commercialRegistration}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CountryInput = () => (
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
||||||
|
<CountrySelect value={country} onChange={setCountry} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PhoneInput = () => (
|
||||||
|
<Input
|
||||||
|
type="tel"
|
||||||
|
name="phone"
|
||||||
|
label="Phone number"
|
||||||
|
onChange={(e) => setPhone(e)}
|
||||||
|
placeholder="Enter phone number"
|
||||||
|
defaultValue={phone}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ExpirationDate = () => (
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Expiry Date (click to purchase)</label>
|
||||||
|
<Link
|
||||||
|
href="/payment"
|
||||||
|
className={clsx(
|
||||||
|
"p-6 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
!user.subscriptionExpirationDate
|
||||||
|
? "!bg-mti-green-ultralight !border-mti-green-light"
|
||||||
|
: expirationDateColor(user.subscriptionExpirationDate),
|
||||||
|
"bg-white border-mti-gray-platinum",
|
||||||
|
)}>
|
||||||
|
{!user.subscriptionExpirationDate && "Unlimited"}
|
||||||
|
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TimezoneInput = () => (
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Timezone</label>
|
||||||
|
<TimezoneSelect value={timezone} onChange={setTimezone} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<section className="w-full flex flex-col gap-4 md:gap-8 px-4 py-8">
|
<section className="w-full flex flex-col gap-4 md:gap-8 px-4 py-8">
|
||||||
@@ -175,16 +262,26 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
<div className="flex flex-col gap-8 w-full md:w-2/3">
|
<div className="flex flex-col gap-8 w-full md:w-2/3">
|
||||||
<h1 className="text-4xl font-bold mb-6 -md:hidden">Edit Profile</h1>
|
<h1 className="text-4xl font-bold mb-6 -md:hidden">Edit Profile</h1>
|
||||||
<form className="flex flex-col items-center gap-6 w-full">
|
<form className="flex flex-col items-center gap-6 w-full">
|
||||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
<DoubleColumnRow>
|
||||||
<Input
|
{user.type !== "corporate" ? (
|
||||||
label="Name"
|
<NameInput />
|
||||||
type="text"
|
) : (
|
||||||
name="name"
|
<Input
|
||||||
onChange={(e) => setName(e)}
|
label="Company name"
|
||||||
placeholder="Enter your name"
|
type="text"
|
||||||
defaultValue={name}
|
name="name"
|
||||||
required
|
onChange={(e) =>
|
||||||
/>
|
setCorporateInformation((prev) => ({
|
||||||
|
...prev!,
|
||||||
|
companyInformation: {...prev!.companyInformation, name: e},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Enter your company's name"
|
||||||
|
defaultValue={corporateInformation?.companyInformation.name}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="E-mail Address"
|
label="E-mail Address"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -194,178 +291,142 @@ function UserProfile({user, mutateUser}: Props) {
|
|||||||
defaultValue={email}
|
defaultValue={email}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</DoubleColumnRow>
|
||||||
{user.type === "student" && (
|
<PasswordInput />
|
||||||
<Input
|
{user.type === "agent" && <AgentInformationInput />}
|
||||||
type="text"
|
|
||||||
name="passport_id"
|
|
||||||
label="Passport/National ID"
|
|
||||||
onChange={(e) => setPassportID(e)}
|
|
||||||
placeholder="Enter National ID or Passport number"
|
|
||||||
value={passport_id}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
|
||||||
<Input
|
|
||||||
label="Current Password"
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
onChange={(e) => setPassword(e)}
|
|
||||||
placeholder="Enter your password"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="New Password"
|
|
||||||
type="password"
|
|
||||||
name="newPassword"
|
|
||||||
onChange={(e) => setNewPassword(e)}
|
|
||||||
placeholder="Enter your new password (optional)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{user.type === "agent" && (
|
<DoubleColumnRow>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
|
<CountryInput />
|
||||||
<Input
|
<PhoneInput />
|
||||||
label="Corporate Name"
|
</DoubleColumnRow>
|
||||||
type="text"
|
|
||||||
name="companyName"
|
|
||||||
onChange={() => null}
|
|
||||||
placeholder="Enter corporate name"
|
|
||||||
defaultValue={companyName}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Commercial Registration"
|
|
||||||
type="text"
|
|
||||||
name="commercialRegistration"
|
|
||||||
onChange={() => null}
|
|
||||||
placeholder="Enter commercial registration"
|
|
||||||
defaultValue={commercialRegistration}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
{user.type === "student" ? (
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<DoubleColumnRow>
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
|
||||||
<CountrySelect value={country} onChange={setCountry} />
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
type="tel"
|
|
||||||
name="phone"
|
|
||||||
label="Phone number"
|
|
||||||
onChange={(e) => setPhone(e)}
|
|
||||||
placeholder="Enter phone number"
|
|
||||||
defaultValue={phone}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col md:flex-row gap-8 w-full">
|
|
||||||
{user.type === "corporate" && (
|
|
||||||
<Input
|
<Input
|
||||||
name="position"
|
|
||||||
onChange={setPosition}
|
|
||||||
defaultValue={position}
|
|
||||||
type="text"
|
type="text"
|
||||||
label="Position"
|
name="passport_id"
|
||||||
placeholder="CEO, Head of Marketing..."
|
label="Passport/National ID"
|
||||||
|
onChange={(e) => setPassportID(e)}
|
||||||
|
placeholder="Enter National ID or Passport number"
|
||||||
|
value={passport_id}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
)}
|
<TimezoneInput />
|
||||||
{user.type !== "corporate" && (
|
</DoubleColumnRow>
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
) : (
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Employment Status *</label>
|
<TimezoneInput />
|
||||||
<RadioGroup
|
)}
|
||||||
value={employment}
|
|
||||||
onChange={setEmployment}
|
<Divider />
|
||||||
className="grid grid-cols-2 items-center gap-4 place-items-center">
|
|
||||||
{EMPLOYMENT_STATUS.map(({status, label}) => (
|
{user.type === "corporate" && (
|
||||||
<RadioGroup.Option value={status} key={status}>
|
<>
|
||||||
{({checked}) => (
|
<DoubleColumnRow>
|
||||||
<span
|
<Input
|
||||||
className={clsx(
|
type="number"
|
||||||
"px-6 py-4 w-40 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
name="companyUsers"
|
||||||
"transition duration-300 ease-in-out",
|
onChange={() => null}
|
||||||
!checked
|
label="Number of users"
|
||||||
? "bg-white border-mti-gray-platinum"
|
defaultValue={user.corporateInformation.companyInformation.userAmount}
|
||||||
: "bg-mti-purple-light border-mti-purple-dark text-white",
|
disabled
|
||||||
)}>
|
required
|
||||||
{label}
|
/>
|
||||||
</span>
|
<Input
|
||||||
)}
|
type="text"
|
||||||
</RadioGroup.Option>
|
name="pricing"
|
||||||
))}
|
onChange={() => null}
|
||||||
</RadioGroup>
|
label="Pricing"
|
||||||
|
defaultValue={`${user.corporateInformation.payment?.value} ${user.corporateInformation.payment?.currency}`}
|
||||||
|
disabled
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</DoubleColumnRow>
|
||||||
|
<ExpirationDate />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user.type === "corporate" && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<DoubleColumnRow>
|
||||||
|
<NameInput />
|
||||||
|
<Input
|
||||||
|
name="position"
|
||||||
|
onChange={setPosition}
|
||||||
|
defaultValue={position}
|
||||||
|
type="text"
|
||||||
|
label="Position"
|
||||||
|
placeholder="CEO, Head of Marketing..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</DoubleColumnRow>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user.type === "corporate" && user.corporateInformation.referralAgent && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<DoubleColumnRow>
|
||||||
|
<Input
|
||||||
|
name="agentName"
|
||||||
|
onChange={() => null}
|
||||||
|
defaultValue={users.find((x) => x.id === user.corporateInformation.referralAgent)?.name}
|
||||||
|
type="text"
|
||||||
|
label="Country Manager's Name"
|
||||||
|
placeholder="Not available"
|
||||||
|
required
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="agentEmail"
|
||||||
|
onChange={() => null}
|
||||||
|
defaultValue={users.find((x) => x.id === user.corporateInformation.referralAgent)?.email}
|
||||||
|
type="text"
|
||||||
|
label="Country Manager's E-mail"
|
||||||
|
placeholder="Not available"
|
||||||
|
required
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</DoubleColumnRow>
|
||||||
|
<DoubleColumnRow>
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Country Manager's Country *</label>
|
||||||
|
<CountrySelect
|
||||||
|
value={
|
||||||
|
users.find((x) => x.id === user.corporateInformation.referralAgent)?.demographicInformation
|
||||||
|
?.country
|
||||||
|
}
|
||||||
|
onChange={() => null}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="tel"
|
||||||
|
name="agentPhone"
|
||||||
|
label="Country Manager's Phone"
|
||||||
|
onChange={() => null}
|
||||||
|
placeholder="Not available"
|
||||||
|
defaultValue={
|
||||||
|
users.find((x) => x.id === user.corporateInformation.referralAgent)?.demographicInformation?.phone
|
||||||
|
}
|
||||||
|
disabled
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</DoubleColumnRow>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user.type !== "corporate" && (
|
||||||
|
<DoubleColumnRow>
|
||||||
|
<EmploymentStatusInput value={employment} onChange={setEmployment} />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-8 w-full">
|
||||||
|
<GenderInput value={gender} onChange={setGender} />
|
||||||
|
<ExpirationDate />
|
||||||
</div>
|
</div>
|
||||||
)}
|
</DoubleColumnRow>
|
||||||
<div className="flex flex-col gap-8 w-full">
|
)}
|
||||||
<div className="relative flex flex-col gap-3 w-full">
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Gender *</label>
|
|
||||||
<RadioGroup value={gender} onChange={setGender} className="flex flex-row gap-4 justify-between">
|
|
||||||
<RadioGroup.Option value="male">
|
|
||||||
{({checked}) => (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
!checked
|
|
||||||
? "bg-white border-mti-gray-platinum"
|
|
||||||
: "bg-mti-purple-light border-mti-purple-dark text-white",
|
|
||||||
)}>
|
|
||||||
Male
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</RadioGroup.Option>
|
|
||||||
<RadioGroup.Option value="female">
|
|
||||||
{({checked}) => (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
!checked
|
|
||||||
? "bg-white border-mti-gray-platinum"
|
|
||||||
: "bg-mti-purple-light border-mti-purple-dark text-white",
|
|
||||||
)}>
|
|
||||||
Female
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</RadioGroup.Option>
|
|
||||||
<RadioGroup.Option value="other">
|
|
||||||
{({checked}) => (
|
|
||||||
<span
|
|
||||||
className={clsx(
|
|
||||||
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
!checked
|
|
||||||
? "bg-white border-mti-gray-platinum"
|
|
||||||
: "bg-mti-purple-light border-mti-purple-dark text-white",
|
|
||||||
)}>
|
|
||||||
Other
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</RadioGroup.Option>
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<label className="font-normal text-base text-mti-gray-dim">Expiry Date (click to purchase)</label>
|
|
||||||
<Link
|
|
||||||
href="/payment"
|
|
||||||
className={clsx(
|
|
||||||
"p-6 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
|
||||||
"transition duration-300 ease-in-out",
|
|
||||||
!user.subscriptionExpirationDate
|
|
||||||
? "!bg-mti-green-ultralight !border-mti-green-light"
|
|
||||||
: expirationDateColor(user.subscriptionExpirationDate),
|
|
||||||
"bg-white border-mti-gray-platinum",
|
|
||||||
)}>
|
|
||||||
{!user.subscriptionExpirationDate && "Unlimited"}
|
|
||||||
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-6 w-48">
|
<div className="flex flex-col gap-6 w-48">
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import useGroups from "@/hooks/useGroups";
|
|||||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||||
import useAssignments from "@/hooks/useAssignments";
|
import useAssignments from "@/hooks/useAssignments";
|
||||||
import {uuidv4} from "@firebase/util";
|
import {uuidv4} from "@firebase/util";
|
||||||
import { usePDFDownload } from "@/hooks/usePDFDownload";
|
import {usePDFDownload} from "@/hooks/usePDFDownload";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
@@ -175,7 +175,7 @@ export default function History({user}: {user: User}) {
|
|||||||
level: calculateBandScore(x.correct, x.total, x.module, user.focus),
|
level: calculateBandScore(x.correct, x.total, x.module, user.focus),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { timeSpent, session } = dateStats[0];
|
const {timeSpent, session} = dateStats[0];
|
||||||
|
|
||||||
const selectExam = () => {
|
const selectExam = () => {
|
||||||
const examPromises = uniqBy(dateStats, "exam").map((stat) => getExamById(stat.module, stat.exam));
|
const examPromises = uniqBy(dateStats, "exam").map((stat) => getExamById(stat.module, stat.exam));
|
||||||
@@ -215,8 +215,7 @@ export default function History({user}: {user: User}) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
<span
|
<span className={textColor}>
|
||||||
className={textColor}>
|
|
||||||
Level{" "}
|
Level{" "}
|
||||||
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -139,9 +139,9 @@ export default function Stats() {
|
|||||||
}
|
}
|
||||||
}, [startDate, endDate]);
|
}, [startDate, endDate]);
|
||||||
|
|
||||||
const calculateTotalScore = (stats: Stat[]) => {
|
const calculateTotalScore = (stats: Stat[], divisionFactor: number) => {
|
||||||
const moduleScores = calculateModuleScore(stats);
|
const moduleScores = calculateModuleScore(stats);
|
||||||
return moduleScores.reduce((acc, curr) => acc + curr.score, 0) / 4;
|
return moduleScores.reduce((acc, curr) => acc + curr.score, 0) / divisionFactor;
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateScorePerModule = (stats: Stat[], module: Module) => {
|
const calculateScorePerModule = (stats: Stat[], module: Module) => {
|
||||||
@@ -278,7 +278,10 @@ export default function Stats() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="px-2">
|
<span className="px-2">
|
||||||
Level{" "}
|
Level{" "}
|
||||||
{calculateTotalScore(stats.filter((s) => timestampToMoment(s).isBefore(date))).toFixed(1)}
|
{calculateTotalScore(
|
||||||
|
stats.filter((s) => timestampToMoment(s).isBefore(date)),
|
||||||
|
5,
|
||||||
|
).toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
@@ -364,6 +367,7 @@ export default function Stats() {
|
|||||||
return date.isValid()
|
return date.isValid()
|
||||||
? calculateTotalScore(
|
? calculateTotalScore(
|
||||||
stats.filter((s) => timestampToMoment(s).isBefore(date)),
|
stats.filter((s) => timestampToMoment(s).isBefore(date)),
|
||||||
|
5,
|
||||||
).toFixed(1)
|
).toFixed(1)
|
||||||
: undefined;
|
: undefined;
|
||||||
})
|
})
|
||||||
@@ -599,188 +603,47 @@ export default function Stats() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex -md:flex-col -md:items-center gap-4 flex-wrap">
|
<div className="flex -md:flex-col -md:items-center gap-4 flex-wrap">
|
||||||
{/* Reading Score Band in Interval */}
|
{/* Module Score Band in Interval */}
|
||||||
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96">
|
{MODULE_ARRAY.map((module, index) => (
|
||||||
<span className="text-sm font-bold">Reading Score Band in Interval</span>
|
<div
|
||||||
<Chart
|
className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96"
|
||||||
options={{
|
key={module}>
|
||||||
scales: {
|
<span className="text-sm font-bold">{capitalize(module)} Score Band in Interval</span>
|
||||||
y: {
|
<Chart
|
||||||
min: 0,
|
options={{
|
||||||
max: 9,
|
scales: {
|
||||||
|
y: {
|
||||||
|
min: 0,
|
||||||
|
max: 9,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}}
|
||||||
}}
|
type="line"
|
||||||
type="line"
|
data={{
|
||||||
data={{
|
labels: intervalDates.map((date) => moment(date).format("DD/MM/YYYY")),
|
||||||
labels: intervalDates.map((date) => moment(date).format("DD/MM/YYYY")),
|
datasets: [
|
||||||
datasets: [
|
{
|
||||||
{
|
type: "line",
|
||||||
type: "line",
|
label: capitalize(module),
|
||||||
label: "Reading",
|
fill: false,
|
||||||
fill: false,
|
borderColor: COLORS[index],
|
||||||
borderColor: COLORS[0],
|
backgroundColor: COLORS[index],
|
||||||
backgroundColor: COLORS[0],
|
borderWidth: 2,
|
||||||
borderWidth: 2,
|
spanGaps: true,
|
||||||
spanGaps: true,
|
data: intervalDates.map((date) => {
|
||||||
data: intervalDates.map((date) => {
|
return calculateTotalScore(
|
||||||
return calculateTotalScore(
|
stats.filter(
|
||||||
stats.filter(
|
(s) => timestampToMoment(s).isBefore(date) && s.module === module,
|
||||||
(s) => timestampToMoment(s).isBefore(date) && s.module === "reading",
|
),
|
||||||
),
|
1,
|
||||||
).toFixed(1);
|
).toFixed(1);
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
{/* Listening Score Band in Interval */}
|
|
||||||
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96">
|
|
||||||
<span className="text-sm font-bold">Listening Score Band in Interval</span>
|
|
||||||
<Chart
|
|
||||||
options={{
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
min: 0,
|
|
||||||
max: 9,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
type="line"
|
|
||||||
data={{
|
|
||||||
labels: intervalDates.map((date) => moment(date).format("DD/MM/YYYY")),
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
type: "line",
|
|
||||||
label: "Listening",
|
|
||||||
fill: false,
|
|
||||||
borderColor: COLORS[1],
|
|
||||||
backgroundColor: COLORS[1],
|
|
||||||
borderWidth: 2,
|
|
||||||
spanGaps: true,
|
|
||||||
data: intervalDates.map((date) => {
|
|
||||||
return calculateTotalScore(
|
|
||||||
stats.filter(
|
|
||||||
(s) => timestampToMoment(s).isBefore(date) && s.module === "listening",
|
|
||||||
),
|
|
||||||
).toFixed(1);
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Writing Score Band in Interval */}
|
|
||||||
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96">
|
|
||||||
<span className="text-sm font-bold">Writing Score Band in Interval</span>
|
|
||||||
<Chart
|
|
||||||
options={{
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
min: 0,
|
|
||||||
max: 9,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
type="line"
|
|
||||||
data={{
|
|
||||||
labels: intervalDates.map((date) => moment(date).format("DD/MM/YYYY")),
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
type: "line",
|
|
||||||
label: "Writing",
|
|
||||||
fill: false,
|
|
||||||
borderColor: COLORS[2],
|
|
||||||
backgroundColor: COLORS[2],
|
|
||||||
borderWidth: 2,
|
|
||||||
spanGaps: true,
|
|
||||||
data: intervalDates.map((date) => {
|
|
||||||
return calculateTotalScore(
|
|
||||||
stats.filter(
|
|
||||||
(s) => timestampToMoment(s).isBefore(date) && s.module === "writing",
|
|
||||||
),
|
|
||||||
).toFixed(1);
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Speaking Score Band in Interval */}
|
|
||||||
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96">
|
|
||||||
<span className="text-sm font-bold">Speaking Score Band in Interval</span>
|
|
||||||
<Chart
|
|
||||||
options={{
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
min: 0,
|
|
||||||
max: 9,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
type="line"
|
|
||||||
data={{
|
|
||||||
labels: intervalDates.map((date) => moment(date).format("DD/MM/YYYY")),
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
type: "line",
|
|
||||||
label: "Speaking",
|
|
||||||
fill: false,
|
|
||||||
borderColor: COLORS[3],
|
|
||||||
backgroundColor: COLORS[3],
|
|
||||||
borderWidth: 2,
|
|
||||||
spanGaps: true,
|
|
||||||
data: intervalDates.map((date) => {
|
|
||||||
return calculateTotalScore(
|
|
||||||
stats.filter(
|
|
||||||
(s) => timestampToMoment(s).isBefore(date) && s.module === "speaking",
|
|
||||||
),
|
|
||||||
).toFixed(1);
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Level Score Band in Interval */}
|
|
||||||
<div className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96">
|
|
||||||
<span className="text-sm font-bold">Level Score Band in Interval</span>
|
|
||||||
<Chart
|
|
||||||
options={{
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
min: 0,
|
|
||||||
max: 9,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
type="line"
|
|
||||||
data={{
|
|
||||||
labels: intervalDates.map((date) => moment(date).format("DD/MM/YYYY")),
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
type: "line",
|
|
||||||
label: "Level",
|
|
||||||
fill: false,
|
|
||||||
borderColor: COLORS[4],
|
|
||||||
backgroundColor: COLORS[4],
|
|
||||||
borderWidth: 2,
|
|
||||||
spanGaps: true,
|
|
||||||
data: intervalDates.map((date) => {
|
|
||||||
return calculateTotalScore(
|
|
||||||
stats.filter((s) => timestampToMoment(s).isBefore(date) && s.module === "level"),
|
|
||||||
).toFixed(1);
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
18
src/utils/groups.ts
Normal file
18
src/utils/groups.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import {CorporateUser, Group, User} from "@/interfaces/user";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export const isUserFromCorporate = async (userID: string) => {
|
||||||
|
const groups = (await axios.get<Group[]>(`/api/groups?participant=${userID}`)).data;
|
||||||
|
const users = (await axios.get<User[]>("/api/users/list")).data;
|
||||||
|
|
||||||
|
const adminTypes = groups.map((g) => users.find((u) => u.id === g.admin)?.type);
|
||||||
|
return adminTypes.includes("corporate");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserCorporate = async (userID: string) => {
|
||||||
|
const groups = (await axios.get<Group[]>(`/api/groups?participant=${userID}`)).data;
|
||||||
|
const users = (await axios.get<User[]>("/api/users/list")).data;
|
||||||
|
|
||||||
|
const admins = groups.map((g) => users.find((u) => u.id === g.admin)).filter((x) => x?.type === "corporate");
|
||||||
|
return admins.length > 0 ? (admins[0] as CorporateUser) : undefined;
|
||||||
|
};
|
||||||
@@ -12,3 +12,16 @@ export function dateSorter(a: any, b: any, direction: "asc" | "desc", key: strin
|
|||||||
export function env(key: string) {
|
export function env(key: string) {
|
||||||
return (window as any).__ENV[key];
|
return (window as any).__ENV[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const convertBase64 = (file: File) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const fileReader = new FileReader();
|
||||||
|
fileReader.readAsDataURL(file);
|
||||||
|
fileReader.onload = () => {
|
||||||
|
resolve(fileReader.result);
|
||||||
|
};
|
||||||
|
fileReader.onerror = (error) => {
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Module} from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import { LevelScore } from "@/constants/ielts";
|
import {LevelScore} from "@/constants/ielts";
|
||||||
|
|
||||||
type Type = "academic" | "general";
|
type Type = "academic" | "general";
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ const academicMarking: {[key: number]: number} = {
|
|||||||
|
|
||||||
const levelMarking: {[key: number]: number} = {
|
const levelMarking: {[key: number]: number} = {
|
||||||
88: 9, // Advanced
|
88: 9, // Advanced
|
||||||
64: 8 , // Upper-Intermediate
|
64: 8, // Upper-Intermediate
|
||||||
52: 6, // Intermediate
|
52: 6, // Intermediate
|
||||||
32: 4, // Pre-Intermediate
|
32: 4, // Pre-Intermediate
|
||||||
16: 2, // Elementary
|
16: 2, // Elementary
|
||||||
@@ -142,23 +142,24 @@ export const calculateBandScore = (correct: number, total: number, module: Modul
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const calculateAverageLevel = (levels: {[key in Module]: number}) => {
|
export const calculateAverageLevel = (levels: {[key in Module]: number}) => {
|
||||||
return Object.keys(levels).reduce((accumulator, current) => levels[current as Module] + accumulator, 0) / 4;
|
return Object.keys(levels).reduce((accumulator, current) => levels[current as Module] + accumulator, 0) / 5;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getLevelScore = (level: number) => {
|
export const getLevelScore = (level: number) => {
|
||||||
switch(level) {
|
switch (level) {
|
||||||
case 0:
|
case 0:
|
||||||
return ['Beginner', 'Low A1'];
|
return ["Beginner", "Low A1"];
|
||||||
case 2:
|
case 2:
|
||||||
return ['Elementary', 'High A1/Low A2'];
|
return ["Elementary", "High A1/Low A2"];
|
||||||
case 4:
|
case 4:
|
||||||
return ['Pre-Intermediate', 'High A2/Low B1'];
|
return ["Pre-Intermediate", "High A2/Low B1"];
|
||||||
case 6:
|
case 6:
|
||||||
return ['Intermediate', 'High B1/Low B2'];
|
return ["Intermediate", "High B1/Low B2"];
|
||||||
case 8:
|
case 8:
|
||||||
return ['Upper-Intermediate', 'High B2/Low C1'];
|
return ["Upper-Intermediate", "High B2/Low C1"];
|
||||||
case 9:
|
case 9:
|
||||||
return ['Advanced', 'C1'];
|
return ["Advanced", "C1"];
|
||||||
default: return [];
|
default:
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|||||||
163
yarn.lock
163
yarn.lock
@@ -10,7 +10,7 @@
|
|||||||
"@babel/highlight" "^7.22.13"
|
"@babel/highlight" "^7.22.13"
|
||||||
chalk "^2.4.2"
|
chalk "^2.4.2"
|
||||||
|
|
||||||
"@babel/helper-module-imports@^7.16.7":
|
"@babel/helper-module-imports@^7.0.0", "@babel/helper-module-imports@^7.16.7":
|
||||||
version "7.22.15"
|
version "7.22.15"
|
||||||
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz#16146307acdc40cc00c3b2c647713076464bdbf0"
|
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz#16146307acdc40cc00c3b2c647713076464bdbf0"
|
||||||
integrity sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==
|
integrity sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==
|
||||||
@@ -62,6 +62,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
regenerator-runtime "^0.13.11"
|
regenerator-runtime "^0.13.11"
|
||||||
|
|
||||||
|
"@babel/runtime@^7.7.2":
|
||||||
|
version "7.23.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.8.tgz#8ee6fe1ac47add7122902f257b8ddf55c898f650"
|
||||||
|
integrity sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw==
|
||||||
|
dependencies:
|
||||||
|
regenerator-runtime "^0.14.0"
|
||||||
|
|
||||||
"@babel/types@^7.22.15":
|
"@babel/types@^7.22.15":
|
||||||
version "7.23.0"
|
version "7.23.0"
|
||||||
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.0.tgz#8c1f020c9df0e737e4e247c0619f58c68458aaeb"
|
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.0.tgz#8c1f020c9df0e737e4e247c0619f58c68458aaeb"
|
||||||
@@ -98,6 +105,16 @@
|
|||||||
source-map "^0.5.7"
|
source-map "^0.5.7"
|
||||||
stylis "4.2.0"
|
stylis "4.2.0"
|
||||||
|
|
||||||
|
"@emotion/cache@^10.0.27":
|
||||||
|
version "10.0.29"
|
||||||
|
resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-10.0.29.tgz#87e7e64f412c060102d589fe7c6dc042e6f9d1e0"
|
||||||
|
integrity sha512-fU2VtSVlHiF27empSbxi1O2JFdNWZO+2NFHfwO0pxgTep6Xa3uGb+3pVKfLww2l/IBGLNEZl5Xf/++A4wAYDYQ==
|
||||||
|
dependencies:
|
||||||
|
"@emotion/sheet" "0.9.4"
|
||||||
|
"@emotion/stylis" "0.8.5"
|
||||||
|
"@emotion/utils" "0.11.3"
|
||||||
|
"@emotion/weak-memoize" "0.2.5"
|
||||||
|
|
||||||
"@emotion/cache@^11.11.0", "@emotion/cache@^11.4.0":
|
"@emotion/cache@^11.11.0", "@emotion/cache@^11.4.0":
|
||||||
version "11.11.0"
|
version "11.11.0"
|
||||||
resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-11.11.0.tgz#809b33ee6b1cb1a625fef7a45bc568ccd9b8f3ff"
|
resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-11.11.0.tgz#809b33ee6b1cb1a625fef7a45bc568ccd9b8f3ff"
|
||||||
@@ -109,6 +126,11 @@
|
|||||||
"@emotion/weak-memoize" "^0.3.1"
|
"@emotion/weak-memoize" "^0.3.1"
|
||||||
stylis "4.2.0"
|
stylis "4.2.0"
|
||||||
|
|
||||||
|
"@emotion/hash@0.8.0":
|
||||||
|
version "0.8.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.8.0.tgz#bbbff68978fefdbe68ccb533bc8cbe1d1afb5413"
|
||||||
|
integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==
|
||||||
|
|
||||||
"@emotion/hash@^0.9.1":
|
"@emotion/hash@^0.9.1":
|
||||||
version "0.9.1"
|
version "0.9.1"
|
||||||
resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.9.1.tgz#4ffb0055f7ef676ebc3a5a91fb621393294e2f43"
|
resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.9.1.tgz#4ffb0055f7ef676ebc3a5a91fb621393294e2f43"
|
||||||
@@ -145,6 +167,17 @@
|
|||||||
"@emotion/weak-memoize" "^0.3.1"
|
"@emotion/weak-memoize" "^0.3.1"
|
||||||
hoist-non-react-statics "^3.3.1"
|
hoist-non-react-statics "^3.3.1"
|
||||||
|
|
||||||
|
"@emotion/serialize@^0.11.15", "@emotion/serialize@^0.11.16":
|
||||||
|
version "0.11.16"
|
||||||
|
resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-0.11.16.tgz#dee05f9e96ad2fb25a5206b6d759b2d1ed3379ad"
|
||||||
|
integrity sha512-G3J4o8by0VRrO+PFeSc3js2myYNOXVJ3Ya+RGVxnshRYgsvErfAOglKAiy1Eo1vhzxqtUvjCyS5gtewzkmvSSg==
|
||||||
|
dependencies:
|
||||||
|
"@emotion/hash" "0.8.0"
|
||||||
|
"@emotion/memoize" "0.7.4"
|
||||||
|
"@emotion/unitless" "0.7.5"
|
||||||
|
"@emotion/utils" "0.11.3"
|
||||||
|
csstype "^2.5.7"
|
||||||
|
|
||||||
"@emotion/serialize@^1.1.2":
|
"@emotion/serialize@^1.1.2":
|
||||||
version "1.1.2"
|
version "1.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-1.1.2.tgz#017a6e4c9b8a803bd576ff3d52a0ea6fa5a62b51"
|
resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-1.1.2.tgz#017a6e4c9b8a803bd576ff3d52a0ea6fa5a62b51"
|
||||||
@@ -156,11 +189,26 @@
|
|||||||
"@emotion/utils" "^1.2.1"
|
"@emotion/utils" "^1.2.1"
|
||||||
csstype "^3.0.2"
|
csstype "^3.0.2"
|
||||||
|
|
||||||
|
"@emotion/sheet@0.9.4":
|
||||||
|
version "0.9.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-0.9.4.tgz#894374bea39ec30f489bbfc3438192b9774d32e5"
|
||||||
|
integrity sha512-zM9PFmgVSqBw4zL101Q0HrBVTGmpAxFZH/pYx/cjJT5advXguvcgjHFTCaIO3enL/xr89vK2bh0Mfyj9aa0ANA==
|
||||||
|
|
||||||
"@emotion/sheet@^1.2.2":
|
"@emotion/sheet@^1.2.2":
|
||||||
version "1.2.2"
|
version "1.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-1.2.2.tgz#d58e788ee27267a14342303e1abb3d508b6d0fec"
|
resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-1.2.2.tgz#d58e788ee27267a14342303e1abb3d508b6d0fec"
|
||||||
integrity sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==
|
integrity sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==
|
||||||
|
|
||||||
|
"@emotion/stylis@0.8.5":
|
||||||
|
version "0.8.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@emotion/stylis/-/stylis-0.8.5.tgz#deacb389bd6ee77d1e7fcaccce9e16c5c7e78e04"
|
||||||
|
integrity sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==
|
||||||
|
|
||||||
|
"@emotion/unitless@0.7.5":
|
||||||
|
version "0.7.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.7.5.tgz#77211291c1900a700b8a78cfafda3160d76949ed"
|
||||||
|
integrity sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==
|
||||||
|
|
||||||
"@emotion/unitless@^0.8.1":
|
"@emotion/unitless@^0.8.1":
|
||||||
version "0.8.1"
|
version "0.8.1"
|
||||||
resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.8.1.tgz#182b5a4704ef8ad91bde93f7a860a88fd92c79a3"
|
resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.8.1.tgz#182b5a4704ef8ad91bde93f7a860a88fd92c79a3"
|
||||||
@@ -171,11 +219,21 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz#08de79f54eb3406f9daaf77c76e35313da963963"
|
resolved "https://registry.yarnpkg.com/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz#08de79f54eb3406f9daaf77c76e35313da963963"
|
||||||
integrity sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==
|
integrity sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==
|
||||||
|
|
||||||
|
"@emotion/utils@0.11.3":
|
||||||
|
version "0.11.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-0.11.3.tgz#a759863867befa7e583400d322652a3f44820924"
|
||||||
|
integrity sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw==
|
||||||
|
|
||||||
"@emotion/utils@^1.2.1":
|
"@emotion/utils@^1.2.1":
|
||||||
version "1.2.1"
|
version "1.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-1.2.1.tgz#bbab58465738d31ae4cb3dbb6fc00a5991f755e4"
|
resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-1.2.1.tgz#bbab58465738d31ae4cb3dbb6fc00a5991f755e4"
|
||||||
integrity sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg==
|
integrity sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg==
|
||||||
|
|
||||||
|
"@emotion/weak-memoize@0.2.5":
|
||||||
|
version "0.2.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46"
|
||||||
|
integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==
|
||||||
|
|
||||||
"@emotion/weak-memoize@^0.3.1":
|
"@emotion/weak-memoize@^0.3.1":
|
||||||
version "0.3.1"
|
version "0.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz#d0fce5d07b0620caa282b5131c297bb60f9d87e6"
|
resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz#d0fce5d07b0620caa282b5131c297bb60f9d87e6"
|
||||||
@@ -1837,6 +1895,31 @@ axobject-query@^3.1.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
deep-equal "^2.0.5"
|
deep-equal "^2.0.5"
|
||||||
|
|
||||||
|
babel-plugin-emotion@^10.0.27:
|
||||||
|
version "10.2.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/babel-plugin-emotion/-/babel-plugin-emotion-10.2.2.tgz#a1fe3503cff80abfd0bdda14abd2e8e57a79d17d"
|
||||||
|
integrity sha512-SMSkGoqTbTyUTDeuVuPIWifPdUGkTk1Kf9BWRiXIOIcuyMfsdp2EjeiiFvOzX8NOBvEh/ypKYvUh2rkgAJMCLA==
|
||||||
|
dependencies:
|
||||||
|
"@babel/helper-module-imports" "^7.0.0"
|
||||||
|
"@emotion/hash" "0.8.0"
|
||||||
|
"@emotion/memoize" "0.7.4"
|
||||||
|
"@emotion/serialize" "^0.11.16"
|
||||||
|
babel-plugin-macros "^2.0.0"
|
||||||
|
babel-plugin-syntax-jsx "^6.18.0"
|
||||||
|
convert-source-map "^1.5.0"
|
||||||
|
escape-string-regexp "^1.0.5"
|
||||||
|
find-root "^1.1.0"
|
||||||
|
source-map "^0.5.7"
|
||||||
|
|
||||||
|
babel-plugin-macros@^2.0.0:
|
||||||
|
version "2.8.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz#0f958a7cc6556b1e65344465d99111a1e5e10138"
|
||||||
|
integrity sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime" "^7.7.2"
|
||||||
|
cosmiconfig "^6.0.0"
|
||||||
|
resolve "^1.12.0"
|
||||||
|
|
||||||
babel-plugin-macros@^3.1.0:
|
babel-plugin-macros@^3.1.0:
|
||||||
version "3.1.0"
|
version "3.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz#9ef6dc74deb934b4db344dc973ee851d148c50c1"
|
resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz#9ef6dc74deb934b4db344dc973ee851d148c50c1"
|
||||||
@@ -1846,6 +1929,11 @@ babel-plugin-macros@^3.1.0:
|
|||||||
cosmiconfig "^7.0.0"
|
cosmiconfig "^7.0.0"
|
||||||
resolve "^1.19.0"
|
resolve "^1.19.0"
|
||||||
|
|
||||||
|
babel-plugin-syntax-jsx@^6.18.0:
|
||||||
|
version "6.18.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946"
|
||||||
|
integrity sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==
|
||||||
|
|
||||||
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"
|
||||||
@@ -2176,6 +2264,17 @@ core-util-is@~1.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
|
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
|
||||||
integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
|
integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
|
||||||
|
|
||||||
|
cosmiconfig@^6.0.0:
|
||||||
|
version "6.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-6.0.0.tgz#da4fee853c52f6b1e6935f41c1a2fc50bd4a9982"
|
||||||
|
integrity sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==
|
||||||
|
dependencies:
|
||||||
|
"@types/parse-json" "^4.0.0"
|
||||||
|
import-fresh "^3.1.0"
|
||||||
|
parse-json "^5.0.0"
|
||||||
|
path-type "^4.0.0"
|
||||||
|
yaml "^1.7.2"
|
||||||
|
|
||||||
cosmiconfig@^7.0.0:
|
cosmiconfig@^7.0.0:
|
||||||
version "7.1.0"
|
version "7.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz#1443b9afa596b670082ea46cbd8f6a62b84635f6"
|
resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz#1443b9afa596b670082ea46cbd8f6a62b84635f6"
|
||||||
@@ -2202,6 +2301,16 @@ country-flag-icons@^1.5.4:
|
|||||||
resolved "https://registry.yarnpkg.com/country-flag-icons/-/country-flag-icons-1.5.7.tgz#f1f2ddf14f3cbf01cba6746374aeba94db35d4b4"
|
resolved "https://registry.yarnpkg.com/country-flag-icons/-/country-flag-icons-1.5.7.tgz#f1f2ddf14f3cbf01cba6746374aeba94db35d4b4"
|
||||||
integrity sha512-AdvXhMcmSp7nBSkpGfW4qR/luAdRUutJqya9PuwRbsBzuoknThfultbv7Ib6fWsHXC43Es/4QJ8gzQQdBNm75A==
|
integrity sha512-AdvXhMcmSp7nBSkpGfW4qR/luAdRUutJqya9PuwRbsBzuoknThfultbv7Ib6fWsHXC43Es/4QJ8gzQQdBNm75A==
|
||||||
|
|
||||||
|
create-emotion@^10.0.14, create-emotion@^10.0.27:
|
||||||
|
version "10.0.27"
|
||||||
|
resolved "https://registry.yarnpkg.com/create-emotion/-/create-emotion-10.0.27.tgz#cb4fa2db750f6ca6f9a001a33fbf1f6c46789503"
|
||||||
|
integrity sha512-fIK73w82HPPn/RsAij7+Zt8eCE8SptcJ3WoRMfxMtjteYxud8GDTKKld7MYwAX2TVhrw29uR1N/bVGxeStHILg==
|
||||||
|
dependencies:
|
||||||
|
"@emotion/cache" "^10.0.27"
|
||||||
|
"@emotion/serialize" "^0.11.15"
|
||||||
|
"@emotion/sheet" "0.9.4"
|
||||||
|
"@emotion/utils" "0.11.3"
|
||||||
|
|
||||||
cross-fetch@^3.1.5:
|
cross-fetch@^3.1.5:
|
||||||
version "3.1.8"
|
version "3.1.8"
|
||||||
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.8.tgz#0327eba65fd68a7d119f8fb2bf9334a1a7956f82"
|
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.8.tgz#0327eba65fd68a7d119f8fb2bf9334a1a7956f82"
|
||||||
@@ -2247,6 +2356,11 @@ cssesc@^3.0.0:
|
|||||||
resolved "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz"
|
resolved "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz"
|
||||||
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
|
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
|
||||||
|
|
||||||
|
csstype@^2.5.7:
|
||||||
|
version "2.6.21"
|
||||||
|
resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.21.tgz#2efb85b7cc55c80017c66a5ad7cbd931fda3a90e"
|
||||||
|
integrity sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==
|
||||||
|
|
||||||
csstype@^3.0.2:
|
csstype@^3.0.2:
|
||||||
version "3.1.1"
|
version "3.1.1"
|
||||||
resolved "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz"
|
resolved "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz"
|
||||||
@@ -2378,6 +2492,11 @@ didyoumean@^1.2.2:
|
|||||||
resolved "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz"
|
resolved "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz"
|
||||||
integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==
|
integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==
|
||||||
|
|
||||||
|
diff@^4.0.1:
|
||||||
|
version "4.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
|
||||||
|
integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
|
||||||
|
|
||||||
dijkstrajs@^1.0.1:
|
dijkstrajs@^1.0.1:
|
||||||
version "1.0.3"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz#4c8dbdea1f0f6478bff94d9c49c784d623e4fc23"
|
resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz#4c8dbdea1f0f6478bff94d9c49c784d623e4fc23"
|
||||||
@@ -2476,6 +2595,14 @@ emoji-regex@^9.2.2:
|
|||||||
resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz"
|
resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz"
|
||||||
integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==
|
integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==
|
||||||
|
|
||||||
|
emotion@^10.0.14:
|
||||||
|
version "10.0.27"
|
||||||
|
resolved "https://registry.yarnpkg.com/emotion/-/emotion-10.0.27.tgz#f9ca5df98630980a23c819a56262560562e5d75e"
|
||||||
|
integrity sha512-2xdDzdWWzue8R8lu4G76uWX5WhyQuzATon9LmNeCy/2BHVC6dsEpfhN1a0qhELgtDVdjyEA6J8Y/VlI5ZnaH0g==
|
||||||
|
dependencies:
|
||||||
|
babel-plugin-emotion "^10.0.27"
|
||||||
|
create-emotion "^10.0.27"
|
||||||
|
|
||||||
encode-utf8@^1.0.3:
|
encode-utf8@^1.0.3:
|
||||||
version "1.0.3"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda"
|
resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda"
|
||||||
@@ -3570,7 +3697,7 @@ ignore@^5.2.0:
|
|||||||
resolved "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz"
|
resolved "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz"
|
||||||
integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==
|
integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==
|
||||||
|
|
||||||
import-fresh@^3.0.0, import-fresh@^3.2.1:
|
import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1:
|
||||||
version "3.3.0"
|
version "3.3.0"
|
||||||
resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz"
|
resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz"
|
||||||
integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==
|
integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==
|
||||||
@@ -4252,7 +4379,7 @@ media-engine@^1.0.3:
|
|||||||
resolved "https://registry.yarnpkg.com/media-engine/-/media-engine-1.0.3.tgz#be3188f6cd243ea2a40804a35de5a5b032f58dad"
|
resolved "https://registry.yarnpkg.com/media-engine/-/media-engine-1.0.3.tgz#be3188f6cd243ea2a40804a35de5a5b032f58dad"
|
||||||
integrity sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==
|
integrity sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==
|
||||||
|
|
||||||
memoize-one@^5.1.1:
|
memoize-one@^5.0.4, memoize-one@^5.1.1:
|
||||||
version "5.2.1"
|
version "5.2.1"
|
||||||
resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz"
|
resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz"
|
||||||
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
|
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
|
||||||
@@ -4360,6 +4487,13 @@ mkdirp@^1.0.3, mkdirp@^1.0.4:
|
|||||||
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
|
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
|
||||||
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
|
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
|
||||||
|
|
||||||
|
moment-timezone@^0.5.44:
|
||||||
|
version "0.5.44"
|
||||||
|
resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.44.tgz#a64a4e47b68a43deeab5ae4eb4f82da77cdf595f"
|
||||||
|
integrity sha512-nv3YpzI/8lkQn0U6RkLd+f0W/zy/JnoR5/EyPz/dNkPTBjA2jNLCVxaiQ8QpeLymhSZvX0wCL5s27NQWdOPwAw==
|
||||||
|
dependencies:
|
||||||
|
moment "^2.29.4"
|
||||||
|
|
||||||
moment@^2.29.4:
|
moment@^2.29.4:
|
||||||
version "2.29.4"
|
version "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"
|
||||||
@@ -5024,6 +5158,18 @@ react-datepicker@^4.18.0:
|
|||||||
react-onclickoutside "^6.13.0"
|
react-onclickoutside "^6.13.0"
|
||||||
react-popper "^2.3.0"
|
react-popper "^2.3.0"
|
||||||
|
|
||||||
|
react-diff-viewer@^3.1.1:
|
||||||
|
version "3.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-diff-viewer/-/react-diff-viewer-3.1.1.tgz#21ac9c891193d05a3734bfd6bd54b107ee6d46cc"
|
||||||
|
integrity sha512-rmvwNdcClp6ZWdS11m1m01UnBA4OwYaLG/li0dB781e/bQEzsGyj+qewVd6W5ztBwseQ72pO7nwaCcq5jnlzcw==
|
||||||
|
dependencies:
|
||||||
|
classnames "^2.2.6"
|
||||||
|
create-emotion "^10.0.14"
|
||||||
|
diff "^4.0.1"
|
||||||
|
emotion "^10.0.14"
|
||||||
|
memoize-one "^5.0.4"
|
||||||
|
prop-types "^15.6.2"
|
||||||
|
|
||||||
react-dom@18.2.0:
|
react-dom@18.2.0:
|
||||||
version "18.2.0"
|
version "18.2.0"
|
||||||
resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz"
|
resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz"
|
||||||
@@ -5261,6 +5407,15 @@ resolve@^1.1.7, resolve@^1.22.1:
|
|||||||
path-parse "^1.0.7"
|
path-parse "^1.0.7"
|
||||||
supports-preserve-symlinks-flag "^1.0.0"
|
supports-preserve-symlinks-flag "^1.0.0"
|
||||||
|
|
||||||
|
resolve@^1.12.0:
|
||||||
|
version "1.22.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d"
|
||||||
|
integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==
|
||||||
|
dependencies:
|
||||||
|
is-core-module "^2.13.0"
|
||||||
|
path-parse "^1.0.7"
|
||||||
|
supports-preserve-symlinks-flag "^1.0.0"
|
||||||
|
|
||||||
resolve@^1.19.0:
|
resolve@^1.19.0:
|
||||||
version "1.22.6"
|
version "1.22.6"
|
||||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.6.tgz#dd209739eca3aef739c626fea1b4f3c506195362"
|
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.6.tgz#dd209739eca3aef739c626fea1b4f3c506195362"
|
||||||
@@ -6200,7 +6355,7 @@ yallist@^4.0.0:
|
|||||||
resolved "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz"
|
resolved "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz"
|
||||||
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
|
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
|
||||||
|
|
||||||
yaml@^1.10.0, yaml@^1.10.2:
|
yaml@^1.10.0, yaml@^1.10.2, yaml@^1.7.2:
|
||||||
version "1.10.2"
|
version "1.10.2"
|
||||||
resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz"
|
resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz"
|
||||||
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
|
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
|
||||||
|
|||||||
Reference in New Issue
Block a user