Compare commits
14 Commits
bug-fixing
...
feature/99
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74dcccf089 | ||
|
|
63d2baf35f | ||
|
|
c02a6a01f4 | ||
|
|
a646955493 | ||
|
|
7a577a7ca2 | ||
|
|
c26ff48b60 | ||
|
|
9ee09c8fda | ||
|
|
d4867fd9a2 | ||
|
|
13e52bfce6 | ||
|
|
5540e4a3e6 | ||
|
|
a18ee93909 | ||
|
|
0641d4250c | ||
|
|
f85a1f5601 | ||
|
|
01a9da3a5b |
@@ -42,6 +42,7 @@
|
||||
"iron-session": "^6.3.1",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
"moment-timezone": "^0.5.44",
|
||||
"next": "13.1.6",
|
||||
"nodemailer": "^6.9.5",
|
||||
"nodemailer-express-handlebars": "^6.1.0",
|
||||
@@ -54,6 +55,7 @@
|
||||
"react-csv": "^2.2.2",
|
||||
"react-currency-input-field": "^3.6.12",
|
||||
"react-datepicker": "^4.18.0",
|
||||
"react-diff-viewer": "^3.1.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-firebase-hooks": "^5.1.1",
|
||||
"react-icons": "^4.8.0",
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,30 +6,11 @@ import Button from "../Low/Button";
|
||||
import {Dialog, Tab, Transition} from "@headlessui/react";
|
||||
import {writingReverseMarking} from "@/utils/score";
|
||||
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) {
|
||||
const [isModalOpen, setIsModalOpen] = 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
const [showDiff, setShowDiff] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -86,16 +67,51 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
|
||||
|
||||
<div className="w-full h-full flex flex-col gap-8">
|
||||
{userSolutions && userSolutions.length > 0 && (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<span>Your answer:</span>
|
||||
<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
|
||||
? formatSolution(
|
||||
userSolutions[0]!.solution.replaceAll("\\n", "\n"),
|
||||
userSolutions[0]!.evaluation.misspelled_pairs,
|
||||
)
|
||||
: userSolutions[0]!.solution.replaceAll("\\n", "\n")}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 w-full relative">
|
||||
{!showDiff && (
|
||||
<>
|
||||
<span>Your answer:</span>
|
||||
<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")}
|
||||
</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>
|
||||
)}
|
||||
{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) => {
|
||||
switch (exercise.type) {
|
||||
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":
|
||||
return <TrueFalseSolution {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <TrueFalseSolution key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
|
||||
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":
|
||||
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":
|
||||
return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
|
||||
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":
|
||||
return <Speaking {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
case "interactiveSpeaking":
|
||||
return <InteractiveSpeaking {...(exercise as InteractiveSpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
return <InteractiveSpeaking key={exercise.id} {...(exercise as InteractiveSpeakingExercise)} onNext={onNext} onBack={onBack} />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -34,8 +34,12 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []);
|
||||
const [name, setName] = useState(assignment?.name || generate({minLength: 6, maxLength: 8, min: 2, max: 3, join: " ", formatter: capitalize}));
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [startDate, setStartDate] = useState<Date | null>(assignment ? moment(assignment.startDate).toDate() : moment().add(1, "day").toDate());
|
||||
const [endDate, setEndDate] = useState<Date | null>(assignment ? moment(assignment.endDate).toDate() : moment().add(8, "day").toDate());
|
||||
const [startDate, setStartDate] = useState<Date | null>(
|
||||
assignment ? moment(assignment.startDate).toDate() : moment().hours(0).minutes(0).add(1, "day").toDate(),
|
||||
);
|
||||
const [endDate, setEndDate] = useState<Date | null>(
|
||||
assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(),
|
||||
);
|
||||
// creates a new exam for each assignee or just one exam for all assignees
|
||||
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
|
||||
|
||||
@@ -49,33 +53,26 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
};
|
||||
|
||||
const createAssignment = () => {
|
||||
setIsLoading(true);
|
||||
setIsLoading(true);
|
||||
|
||||
(assignment ? axios.patch : axios.post)(
|
||||
`/api/assignments${assignment ? `/${assignment.id}` : ""}`,
|
||||
{
|
||||
assignees,
|
||||
name,
|
||||
startDate,
|
||||
endDate,
|
||||
selectedModules,
|
||||
generateMultiple,
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
toast.success(
|
||||
`The assignment "${name}" has been ${
|
||||
assignment ? "updated" : "created"
|
||||
} successfully!`
|
||||
);
|
||||
cancelCreation();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
(assignment ? axios.patch : axios.post)(`/api/assignments${assignment ? `/${assignment.id}` : ""}`, {
|
||||
assignees,
|
||||
name,
|
||||
startDate,
|
||||
endDate,
|
||||
selectedModules,
|
||||
generateMultiple,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
|
||||
cancelCreation();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
const deleteAssignment = () => {
|
||||
if (assignment) {
|
||||
@@ -285,7 +282,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
|
||||
</div>
|
||||
</section>
|
||||
<div className="flex gap-4 w-full justify-end">
|
||||
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple(d => !d)}>
|
||||
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
|
||||
Generate different exams
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function StudentDashboard({user}: Props) {
|
||||
const setAssignment = useExamStore((state) => state.setAssignment);
|
||||
|
||||
useEffect(() => {
|
||||
getUserCorporate(user.id).then(setCorporateUserToShow);
|
||||
getUserCorporate("IXdh9EQziAVXXh0jOiC5cPVlgS82").then(setCorporateUserToShow);
|
||||
}, [user]);
|
||||
|
||||
const startAssignment = (assignment: Assignment) => {
|
||||
@@ -70,7 +70,7 @@ export default function StudentDashboard({user}: Props) {
|
||||
<>
|
||||
{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>
|
||||
Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b>
|
||||
</div>
|
||||
)}
|
||||
<ProfileSummary
|
||||
|
||||
@@ -244,7 +244,7 @@ export default function TeacherDashboard({user}: Props) {
|
||||
<>
|
||||
{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>
|
||||
Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b>
|
||||
</div>
|
||||
)}
|
||||
<section
|
||||
|
||||
@@ -40,3 +40,15 @@ export function prepareMailOptions(context: object, to: string[], subject: strin
|
||||
context,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendEmail(template: string, context: object, to: string[], subject: string): Promise<boolean> {
|
||||
try {
|
||||
const transport = prepareMailer(template);
|
||||
const mailOptions = prepareMailOptions(context, to, subject, template);
|
||||
|
||||
await transport.sendMail(mailOptions);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
28
src/email/templates/assignment.handlebars
Normal file
28
src/email/templates/assignment.handlebars
Normal file
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<div>
|
||||
<p>Hello {{user.name}},</p>
|
||||
<br />
|
||||
<p>You have just been given the assignment <b>"{{assignment.name}}"</b> by your teacher {{assignment.assigner}}!</p>
|
||||
<br />
|
||||
<p>It's start date will be on <b>{{assignment.startDate}}</b> and will only last until <b>{{assignment.endDate}}</b>
|
||||
</p>
|
||||
<br />
|
||||
<p>For this assignment, you've been tasked with completing exams of the following modules:
|
||||
<b>{{assignment.modules}}</b>.
|
||||
</p>
|
||||
<br />
|
||||
<p>Don't forget to do it before its end date!</p>
|
||||
<p>Click <b><a href="https://platform.encoach.com">here</a></b> to open the EnCoach Platform!</p>
|
||||
<br />
|
||||
<p>Thanks,</p>
|
||||
<p>Your EnCoach team</p>
|
||||
</div>
|
||||
|
||||
</html>
|
||||
13
src/email/templates/assignment.handlebars.json
Normal file
13
src/email/templates/assignment.handlebars.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"user": {
|
||||
"name": "Tiago Ribeiro"
|
||||
},
|
||||
"assignment": {
|
||||
"name": "Final Exam",
|
||||
"assigner": "Teacher",
|
||||
"assignees": [],
|
||||
"modules": "Reading and Writing",
|
||||
"startDate": "24/12/2023",
|
||||
"endDate": "27/01/2024"
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,10 @@ export default function useUser({redirectTo = "", redirectIfFound = false} = {})
|
||||
if (!redirectTo || !user) return;
|
||||
|
||||
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
|
||||
(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);
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ interface InteractiveSpeakingEvaluation extends Evaluation {
|
||||
interface CommonEvaluation extends Evaluation {
|
||||
perfect_answer?: string;
|
||||
perfect_answer_1?: string;
|
||||
fixed_text?: string;
|
||||
}
|
||||
|
||||
export interface WritingExercise {
|
||||
|
||||
@@ -78,6 +78,7 @@ export interface DemographicInformation {
|
||||
gender: Gender;
|
||||
employment: EmploymentStatus;
|
||||
passport_id?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export interface DemographicCorporateInformation {
|
||||
@@ -85,6 +86,7 @@ export interface DemographicCorporateInformation {
|
||||
phone: string;
|
||||
gender: Gender;
|
||||
position: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export type Gender = "male" | "female" | "other";
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function BatchCodeGenerator({user}: {user: User}) {
|
||||
return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email)
|
||||
? {
|
||||
email: email.toString(),
|
||||
name: `${firstName} ${lastName}`,
|
||||
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
|
||||
passport_id: passport_id.toString(),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -1,496 +1,434 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { app, storage } from "@/firebase";
|
||||
import {
|
||||
getFirestore,
|
||||
doc,
|
||||
getDoc,
|
||||
updateDoc,
|
||||
getDocs,
|
||||
query,
|
||||
collection,
|
||||
where,
|
||||
documentId,
|
||||
} from "firebase/firestore";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import type {NextApiRequest, NextApiResponse} from "next";
|
||||
import {app, storage} from "@/firebase";
|
||||
import {getFirestore, doc, getDoc, updateDoc, getDocs, query, collection, where, documentId} from "firebase/firestore";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import ReactPDF from "@react-pdf/renderer";
|
||||
import GroupTestReport from "@/exams/pdf/group.test.report";
|
||||
import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
|
||||
import { Stat, CorporateUser } from "@/interfaces/user";
|
||||
import { User, DemographicInformation } from "@/interfaces/user";
|
||||
import { Module } from "@/interfaces";
|
||||
import { ModuleScore, StudentData } from "@/interfaces/module.scores";
|
||||
import { SkillExamDetails } from "@/exams/pdf/details/skill.exam";
|
||||
import { LevelExamDetails } from "@/exams/pdf/details/level.exam";
|
||||
import { calculateBandScore, getLevelScore } from "@/utils/score";
|
||||
import {
|
||||
generateQRCode,
|
||||
getRadialProgressPNG,
|
||||
streamToBuffer,
|
||||
} from "@/utils/pdf";
|
||||
import { Group } from "@/interfaces/user";
|
||||
import {ref, uploadBytes, getDownloadURL} from "firebase/storage";
|
||||
import {Stat, CorporateUser} from "@/interfaces/user";
|
||||
import {User, DemographicInformation} from "@/interfaces/user";
|
||||
import {Module} from "@/interfaces";
|
||||
import {ModuleScore, StudentData} from "@/interfaces/module.scores";
|
||||
import {SkillExamDetails} from "@/exams/pdf/details/skill.exam";
|
||||
import {LevelExamDetails} from "@/exams/pdf/details/level.exam";
|
||||
import {calculateBandScore, getLevelScore} from "@/utils/score";
|
||||
import {generateQRCode, getRadialProgressPNG, streamToBuffer} from "@/utils/pdf";
|
||||
import {Group} from "@/interfaces/user";
|
||||
import moment from "moment-timezone";
|
||||
|
||||
interface GroupScoreSummaryHelper {
|
||||
score: [number, number];
|
||||
label: string;
|
||||
sessions: string[];
|
||||
score: [number, number];
|
||||
label: string;
|
||||
sessions: string[];
|
||||
}
|
||||
const db = getFirestore(app);
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") return get(req, res);
|
||||
if (req.method === "POST") return post(req, res);
|
||||
if (req.method === "GET") return get(req, res);
|
||||
if (req.method === "POST") return post(req, res);
|
||||
}
|
||||
|
||||
const getExamSummary = (score: number) => {
|
||||
if (score > 0.8) {
|
||||
return "Scoring between 81% and 100% on the English exam 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.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.";
|
||||
}
|
||||
|
||||
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.";
|
||||
}
|
||||
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.";
|
||||
}
|
||||
|
||||
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.";
|
||||
}
|
||||
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.";
|
||||
}
|
||||
|
||||
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.";
|
||||
}
|
||||
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 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) => {
|
||||
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.";
|
||||
}
|
||||
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.";
|
||||
}
|
||||
|
||||
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.";
|
||||
}
|
||||
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.";
|
||||
}
|
||||
|
||||
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.";
|
||||
}
|
||||
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.";
|
||||
}
|
||||
|
||||
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.";
|
||||
}
|
||||
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 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) => {
|
||||
if (module === "level") return getLevelSummary(score);
|
||||
return getExamSummary(score);
|
||||
if (module === "level") return getLevelSummary(score);
|
||||
return getExamSummary(score);
|
||||
};
|
||||
|
||||
const getScoreAndTotal = (stats: Stat[]) => {
|
||||
return stats.reduce(
|
||||
(acc, { score }) => {
|
||||
return {
|
||||
...acc,
|
||||
correct: acc.correct + score.correct,
|
||||
total: acc.total + score.total,
|
||||
};
|
||||
},
|
||||
{ correct: 0, total: 0 }
|
||||
);
|
||||
return stats.reduce(
|
||||
(acc, {score}) => {
|
||||
return {
|
||||
...acc,
|
||||
correct: acc.correct + score.correct,
|
||||
total: acc.total + score.total,
|
||||
};
|
||||
},
|
||||
{correct: 0, total: 0},
|
||||
);
|
||||
};
|
||||
|
||||
const getLevelScoreForUserExams = (bandScore: number) => {
|
||||
const [level] = getLevelScore(bandScore);
|
||||
return level;
|
||||
const [level] = getLevelScore(bandScore);
|
||||
return level;
|
||||
};
|
||||
|
||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
// verify if it's a logged user that is trying to export
|
||||
if (req.session.user) {
|
||||
const { id } = req.query as { id: string };
|
||||
// verify if it's a logged user that is trying to export
|
||||
if (req.session.user) {
|
||||
const {id} = req.query as {id: string};
|
||||
|
||||
const docSnap = await getDoc(doc(db, "assignments", id));
|
||||
const data = docSnap.data() as {
|
||||
assigner: string;
|
||||
assignees: string[];
|
||||
results: any;
|
||||
exams: { module: Module }[];
|
||||
startDate: string;
|
||||
pdf?: string;
|
||||
};
|
||||
if (!data) {
|
||||
res.status(400).end();
|
||||
return;
|
||||
}
|
||||
const docSnap = await getDoc(doc(db, "assignments", id));
|
||||
const data = docSnap.data() as {
|
||||
assigner: string;
|
||||
assignees: string[];
|
||||
results: any;
|
||||
exams: {module: Module}[];
|
||||
startDate: string;
|
||||
pdf?: string;
|
||||
};
|
||||
if (!data) {
|
||||
res.status(400).end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.assigner !== req.session.user.id) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
if (data.pdf) {
|
||||
// if it does, return the pdf url
|
||||
const fileRef = ref(storage, data.pdf);
|
||||
const url = await getDownloadURL(fileRef);
|
||||
if (data.assigner !== req.session.user.id) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
if (data.pdf) {
|
||||
// if it does, return the pdf url
|
||||
const fileRef = ref(storage, data.pdf);
|
||||
const url = await getDownloadURL(fileRef);
|
||||
|
||||
res.status(200).end(url);
|
||||
return;
|
||||
}
|
||||
res.status(200).end(url);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const docUser = await getDoc(doc(db, "users", req.session.user.id));
|
||||
if (docUser.exists()) {
|
||||
// we'll need the user in order to get the user data (name, email, focus, etc);
|
||||
const user = docUser.data() as User;
|
||||
try {
|
||||
const docUser = await getDoc(doc(db, "users", req.session.user.id));
|
||||
if (docUser.exists()) {
|
||||
// we'll need the user in order to get the user data (name, email, focus, etc);
|
||||
const user = docUser.data() as User;
|
||||
|
||||
// generate the QR code for the report
|
||||
const qrcode = await generateQRCode(
|
||||
(req.headers.origin || "") + req.url
|
||||
);
|
||||
// generate the QR code for the report
|
||||
const qrcode = await generateQRCode((req.headers.origin || "") + req.url);
|
||||
|
||||
if (!qrcode) {
|
||||
res.status(500).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
if (!qrcode) {
|
||||
res.status(500).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
const flattenResults = data.results.reduce(
|
||||
(accm: Stat[], entry: any) => {
|
||||
const stats = entry.stats as Stat[];
|
||||
return [...accm, ...stats];
|
||||
},
|
||||
[]
|
||||
) as Stat[];
|
||||
const flattenResults = data.results.reduce((accm: Stat[], entry: any) => {
|
||||
const stats = entry.stats as Stat[];
|
||||
return [...accm, ...stats];
|
||||
}, []) as Stat[];
|
||||
|
||||
const docsSnap = await getDocs(
|
||||
query(
|
||||
collection(db, "users"),
|
||||
where(documentId(), "in", data.assignees)
|
||||
)
|
||||
);
|
||||
const users = docsSnap.docs.map((d) => ({
|
||||
...d.data(),
|
||||
id: d.id,
|
||||
})) as User[];
|
||||
const docsSnap = await getDocs(query(collection(db, "users"), where(documentId(), "in", data.assignees)));
|
||||
const users = docsSnap.docs.map((d) => ({
|
||||
...d.data(),
|
||||
id: d.id,
|
||||
})) as User[];
|
||||
|
||||
const flattenResultsWithGrade = flattenResults.map((e) => {
|
||||
const focus = users.find((u) => u.id === e.user)?.focus || "academic";
|
||||
const bandScore = calculateBandScore(
|
||||
e.score.correct,
|
||||
e.score.total,
|
||||
e.module,
|
||||
focus
|
||||
);
|
||||
const flattenResultsWithGrade = flattenResults.map((e) => {
|
||||
const focus = users.find((u) => u.id === e.user)?.focus || "academic";
|
||||
const bandScore = calculateBandScore(e.score.correct, e.score.total, e.module, focus);
|
||||
|
||||
return { ...e, bandScore };
|
||||
});
|
||||
return {...e, bandScore};
|
||||
});
|
||||
|
||||
const moduleResults = data.exams.map(({ module }) => {
|
||||
const moduleResults = flattenResultsWithGrade.filter(
|
||||
(e) => e.module === module
|
||||
);
|
||||
const moduleResults = data.exams.map(({module}) => {
|
||||
const moduleResults = flattenResultsWithGrade.filter((e) => e.module === module);
|
||||
|
||||
const baseBandScore =
|
||||
moduleResults.reduce((accm, curr) => accm + curr.bandScore, 0) /
|
||||
moduleResults.length;
|
||||
const bandScore = isNaN(baseBandScore) ? 0 : baseBandScore;
|
||||
const { correct, total } = getScoreAndTotal(moduleResults);
|
||||
const png = getRadialProgressPNG("azul", correct, total);
|
||||
const baseBandScore = moduleResults.reduce((accm, curr) => accm + curr.bandScore, 0) / moduleResults.length;
|
||||
const bandScore = isNaN(baseBandScore) ? 0 : baseBandScore;
|
||||
const {correct, total} = getScoreAndTotal(moduleResults);
|
||||
const png = getRadialProgressPNG("azul", correct, total);
|
||||
|
||||
return {
|
||||
bandScore,
|
||||
png,
|
||||
module: module[0].toUpperCase() + module.substring(1),
|
||||
score: bandScore,
|
||||
total,
|
||||
code: module,
|
||||
};
|
||||
}) as ModuleScore[];
|
||||
return {
|
||||
bandScore,
|
||||
png,
|
||||
module: module[0].toUpperCase() + module.substring(1),
|
||||
score: bandScore,
|
||||
total,
|
||||
code: module,
|
||||
};
|
||||
}) as ModuleScore[];
|
||||
|
||||
const { correct: overallCorrect, total: overallTotal } =
|
||||
getScoreAndTotal(flattenResults);
|
||||
const baseOverallResult = overallCorrect / overallTotal;
|
||||
const overallResult = isNaN(baseOverallResult) ? 0 : baseOverallResult;
|
||||
const {correct: overallCorrect, total: overallTotal} = getScoreAndTotal(flattenResults);
|
||||
const baseOverallResult = overallCorrect / overallTotal;
|
||||
const overallResult = isNaN(baseOverallResult) ? 0 : baseOverallResult;
|
||||
|
||||
const overallPNG = getRadialProgressPNG(
|
||||
"laranja",
|
||||
overallCorrect,
|
||||
overallTotal
|
||||
);
|
||||
// generate the overall detail report
|
||||
const overallDetail = {
|
||||
module: "Overall",
|
||||
score: overallCorrect,
|
||||
total: overallTotal,
|
||||
png: overallPNG,
|
||||
} as ModuleScore;
|
||||
const overallPNG = getRadialProgressPNG("laranja", overallCorrect, overallTotal);
|
||||
// generate the overall detail report
|
||||
const overallDetail = {
|
||||
module: "Overall",
|
||||
score: overallCorrect,
|
||||
total: overallTotal,
|
||||
png: overallPNG,
|
||||
} as ModuleScore;
|
||||
|
||||
const testDetails = [overallDetail, ...moduleResults];
|
||||
// generate the performance summary based on the overall result
|
||||
const baseStat = data.exams[0];
|
||||
const performanceSummary = getPerformanceSummary(
|
||||
// from what I noticed, exams is either an array with the level module
|
||||
// or X modules, either way
|
||||
// as long as I verify the first entry I should be fine
|
||||
baseStat.module,
|
||||
overallResult
|
||||
);
|
||||
const testDetails = [overallDetail, ...moduleResults];
|
||||
// generate the performance summary based on the overall result
|
||||
const baseStat = data.exams[0];
|
||||
const performanceSummary = getPerformanceSummary(
|
||||
// from what I noticed, exams is either an array with the level module
|
||||
// or X modules, either way
|
||||
// as long as I verify the first entry I should be fine
|
||||
baseStat.module,
|
||||
overallResult,
|
||||
);
|
||||
|
||||
const showLevel = baseStat.module === "level";
|
||||
const showLevel = baseStat.module === "level";
|
||||
|
||||
// level exams have a different report structure than the skill exams
|
||||
const getCustomData = () => {
|
||||
if (showLevel) {
|
||||
return {
|
||||
title: "GROUP ENGLISH LEVEL TEST RESULT REPORT ",
|
||||
details: (
|
||||
<LevelExamDetails
|
||||
detail={overallDetail}
|
||||
title="Group Average CEFR"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
// level exams have a different report structure than the skill exams
|
||||
const getCustomData = () => {
|
||||
if (showLevel) {
|
||||
return {
|
||||
title: "GROUP ENGLISH LEVEL TEST RESULT REPORT ",
|
||||
details: <LevelExamDetails detail={overallDetail} title="Group Average CEFR" />,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: "GROUP ENGLISH SKILLS TEST RESULT REPORT",
|
||||
details: <SkillExamDetails testDetails={testDetails} />,
|
||||
};
|
||||
};
|
||||
return {
|
||||
title: "GROUP ENGLISH SKILLS TEST RESULT REPORT",
|
||||
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[]> => {
|
||||
return data.assignees.map((id) => {
|
||||
const user = users.find((u) => u.id === id);
|
||||
const exams = flattenResultsWithGrade.filter((e) => e.user === id);
|
||||
const date =
|
||||
exams.length === 0
|
||||
? "N/A"
|
||||
: new Date(exams[0].date).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
});
|
||||
const getStudentsData = async (): Promise<StudentData[]> => {
|
||||
return data.assignees.map((id) => {
|
||||
const user = users.find((u) => u.id === id);
|
||||
const exams = flattenResultsWithGrade.filter((e) => e.user === id);
|
||||
const date =
|
||||
exams.length === 0
|
||||
? "N/A"
|
||||
: new Date(exams[0].date).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const bandScore =
|
||||
exams.length === 0
|
||||
? 0
|
||||
: exams.reduce((accm, curr) => accm + curr.bandScore, 0) /
|
||||
exams.length;
|
||||
const { correct, total } = getScoreAndTotal(exams);
|
||||
const bandScore = exams.length === 0 ? 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 {
|
||||
id,
|
||||
name: user?.name || "N/A",
|
||||
email: user?.email || "N/A",
|
||||
gender: user?.demographicInformation?.gender || "N/A",
|
||||
date,
|
||||
result,
|
||||
level: showLevel
|
||||
? getLevelScoreForUserExams(bandScore)
|
||||
: undefined,
|
||||
bandScore,
|
||||
};
|
||||
});
|
||||
};
|
||||
return {
|
||||
id,
|
||||
name: user?.name || "N/A",
|
||||
email: user?.email || "N/A",
|
||||
gender: user?.demographicInformation?.gender || "N/A",
|
||||
date,
|
||||
result,
|
||||
level: showLevel ? getLevelScoreForUserExams(bandScore) : undefined,
|
||||
bandScore,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const studentsData = await getStudentsData();
|
||||
const studentsData = await getStudentsData();
|
||||
|
||||
const getGroupScoreSummary = () => {
|
||||
const resultHelper = studentsData.reduce(
|
||||
(accm: GroupScoreSummaryHelper[], curr) => {
|
||||
const { bandScore, id } = curr;
|
||||
const getGroupScoreSummary = () => {
|
||||
const resultHelper = studentsData.reduce((accm: GroupScoreSummaryHelper[], curr) => {
|
||||
const {bandScore, id} = curr;
|
||||
|
||||
const flooredScore = Math.floor(bandScore);
|
||||
const flooredScore = Math.floor(bandScore);
|
||||
|
||||
const hasMatch = accm.find((a) => a.score.includes(flooredScore));
|
||||
if (hasMatch) {
|
||||
return accm.map((a) => {
|
||||
if (a.score.includes(flooredScore)) {
|
||||
return {
|
||||
...a,
|
||||
sessions: [...a.sessions, id],
|
||||
};
|
||||
}
|
||||
const hasMatch = accm.find((a) => a.score.includes(flooredScore));
|
||||
if (hasMatch) {
|
||||
return accm.map((a) => {
|
||||
if (a.score.includes(flooredScore)) {
|
||||
return {
|
||||
...a,
|
||||
sessions: [...a.sessions, id],
|
||||
};
|
||||
}
|
||||
|
||||
return a;
|
||||
});
|
||||
}
|
||||
return a;
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
...accm,
|
||||
{
|
||||
score: [flooredScore, flooredScore + 0.5],
|
||||
label: `${flooredScore} - ${flooredScore + 0.5}`,
|
||||
sessions: [id],
|
||||
},
|
||||
];
|
||||
},
|
||||
[]
|
||||
) as GroupScoreSummaryHelper[];
|
||||
return [
|
||||
...accm,
|
||||
{
|
||||
score: [flooredScore, flooredScore + 0.5],
|
||||
label: `${flooredScore} - ${flooredScore + 0.5}`,
|
||||
sessions: [id],
|
||||
},
|
||||
];
|
||||
}, []) as GroupScoreSummaryHelper[];
|
||||
|
||||
const result = resultHelper.map(({ score, label, sessions }) => {
|
||||
const finalLabel = showLevel ? getLevelScore(score[0])[1] : label;
|
||||
return {
|
||||
label: finalLabel,
|
||||
percent: Math.floor((sessions.length / numberOfStudents) * 100),
|
||||
description: `No. Candidates ${sessions.length} of ${numberOfStudents}`,
|
||||
};
|
||||
});
|
||||
return result;
|
||||
};
|
||||
const result = resultHelper.map(({score, label, sessions}) => {
|
||||
const finalLabel = showLevel ? getLevelScore(score[0])[1] : label;
|
||||
return {
|
||||
label: finalLabel,
|
||||
percent: Math.floor((sessions.length / numberOfStudents) * 100),
|
||||
description: `No. Candidates ${sessions.length} of ${numberOfStudents}`,
|
||||
};
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
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 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;
|
||||
|
||||
if (assignerUser.type === "teacher") {
|
||||
// also search for groups where this user belongs
|
||||
const queryGroups = query(
|
||||
collection(db, "groups"),
|
||||
where("participants", "array-contains", assignerUser.id)
|
||||
);
|
||||
const groupSnapshot = await getDocs(queryGroups);
|
||||
if (assignerUser.type === "teacher") {
|
||||
// also search for groups where this user belongs
|
||||
const queryGroups = query(collection(db, "groups"), where("participants", "array-contains", assignerUser.id));
|
||||
const groupSnapshot = await getDocs(queryGroups);
|
||||
|
||||
const groups = groupSnapshot.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
})) as Group[];
|
||||
const groups = groupSnapshot.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
})) as Group[];
|
||||
|
||||
if (groups.length > 0) {
|
||||
const adminQuery = query(
|
||||
collection(db, "users"),
|
||||
where(
|
||||
documentId(),
|
||||
"in",
|
||||
groups.map((g) => g.admin)
|
||||
)
|
||||
);
|
||||
const adminUsersSnap = await getDocs(adminQuery);
|
||||
if (groups.length > 0) {
|
||||
const adminQuery = query(
|
||||
collection(db, "users"),
|
||||
where(
|
||||
documentId(),
|
||||
"in",
|
||||
groups.map((g) => g.admin),
|
||||
),
|
||||
);
|
||||
const adminUsersSnap = await getDocs(adminQuery);
|
||||
|
||||
const admins = adminUsersSnap.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
})) as CorporateUser[];
|
||||
const admins = adminUsersSnap.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
})) as CorporateUser[];
|
||||
|
||||
const adminData = admins.find(
|
||||
(a) => a.corporateInformation?.companyInformation?.name
|
||||
);
|
||||
if (adminData) {
|
||||
return adminData.corporateInformation.companyInformation
|
||||
.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
const adminData = admins.find((a) => a.corporateInformation?.companyInformation?.name);
|
||||
if (adminData) {
|
||||
return adminData.corporateInformation.companyInformation.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
assignerUser.type === "corporate" &&
|
||||
assignerUser.corporateInformation?.companyInformation?.name
|
||||
) {
|
||||
return assignerUser.corporateInformation.companyInformation
|
||||
.name;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
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={new Date(data.startDate).toLocaleString()}
|
||||
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 || ""}
|
||||
/>
|
||||
);
|
||||
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);
|
||||
// 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",
|
||||
});
|
||||
// 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;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
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) {
|
||||
if (req.session.user) {
|
||||
const { id } = req.query as { id: string };
|
||||
if (req.session.user) {
|
||||
const {id} = req.query as {id: string};
|
||||
|
||||
const docSnap = await getDoc(doc(db, "assignments", id));
|
||||
const data = docSnap.data();
|
||||
if (!data) {
|
||||
res.status(400).end();
|
||||
return;
|
||||
}
|
||||
const docSnap = await getDoc(doc(db, "assignments", id));
|
||||
const data = docSnap.data();
|
||||
if (!data) {
|
||||
res.status(400).end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.assigner !== req.session.user.id) {
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
}
|
||||
if (data.assigner !== req.session.user.id) {
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.pdf) {
|
||||
const fileRef = ref(storage, data.pdf);
|
||||
const url = await getDownloadURL(fileRef);
|
||||
return res.redirect(url);
|
||||
}
|
||||
if (data.pdf) {
|
||||
const fileRef = ref(storage, data.pdf);
|
||||
const url = await getDownloadURL(fileRef);
|
||||
return res.redirect(url);
|
||||
}
|
||||
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(401).json({ ok: false });
|
||||
return;
|
||||
res.status(401).json({ok: false});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type {NextApiRequest, NextApiResponse} from "next";
|
||||
import {app} from "@/firebase";
|
||||
import {getFirestore, collection, getDocs, query, where, setDoc, doc} from "firebase/firestore";
|
||||
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc} from "firebase/firestore";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {uuidv4} from "@firebase/util";
|
||||
import { Module } from "@/interfaces";
|
||||
import { getExams } from "@/utils/exams.be";
|
||||
import { Exam } from "@/interfaces/exam";
|
||||
import { flatten } from "lodash";
|
||||
import {Module} from "@/interfaces";
|
||||
import {getExams} from "@/utils/exams.be";
|
||||
import {Exam} from "@/interfaces/exam";
|
||||
import {capitalize, flatten} from "lodash";
|
||||
import {User} from "@/interfaces/user";
|
||||
import moment from "moment";
|
||||
import {sendEmail} from "@/email";
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
@@ -39,106 +42,108 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
|
||||
}
|
||||
|
||||
interface ExamWithUser {
|
||||
module: Module;
|
||||
id: string;
|
||||
assignee: string;
|
||||
module: Module;
|
||||
id: string;
|
||||
assignee: string;
|
||||
}
|
||||
|
||||
function getRandomIndex(arr: any[]): number {
|
||||
const randomIndex = Math.floor(Math.random() * arr.length);
|
||||
return randomIndex;
|
||||
const randomIndex = Math.floor(Math.random() * arr.length);
|
||||
return randomIndex;
|
||||
}
|
||||
|
||||
const generateExams = async (
|
||||
generateMultiple: Boolean,
|
||||
selectedModules: Module[],
|
||||
assignees: string[]
|
||||
): Promise<ExamWithUser[]> => {
|
||||
if (generateMultiple) {
|
||||
// for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once
|
||||
const allExams = await assignees.map(async (assignee) => {
|
||||
const selectedModulePromises = await selectedModules.map(
|
||||
async (module: Module) => {
|
||||
try {
|
||||
const exams: Exam[] = await getExams(db, module, "true", assignee);
|
||||
const generateExams = async (generateMultiple: Boolean, selectedModules: Module[], assignees: string[]): Promise<ExamWithUser[]> => {
|
||||
if (generateMultiple) {
|
||||
// for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once
|
||||
const allExams = await assignees.map(async (assignee) => {
|
||||
const selectedModulePromises = await selectedModules.map(async (module: Module) => {
|
||||
try {
|
||||
const exams: Exam[] = await getExams(db, module, "true", assignee);
|
||||
|
||||
const exam = exams[getRandomIndex(exams)];
|
||||
if (exam) {
|
||||
return { module: exam.module, id: exam.id, assignee };
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
const newModules = await Promise.all(selectedModulePromises);
|
||||
const exam = exams[getRandomIndex(exams)];
|
||||
if (exam) {
|
||||
return {module: exam.module, id: exam.id, assignee};
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
const newModules = await Promise.all(selectedModulePromises);
|
||||
|
||||
return newModules;
|
||||
}, []);
|
||||
return newModules;
|
||||
}, []);
|
||||
|
||||
const exams = flatten(await Promise.all(allExams)).filter(
|
||||
(x) => x !== null
|
||||
) as ExamWithUser[];
|
||||
return exams;
|
||||
}
|
||||
const exams = flatten(await Promise.all(allExams)).filter((x) => x !== null) as ExamWithUser[];
|
||||
return exams;
|
||||
}
|
||||
|
||||
const selectedModulePromises = await selectedModules.map(
|
||||
async (module: Module) => {
|
||||
const exams: Exam[] = await getExams(db, module, "false", undefined);
|
||||
const exam = exams[getRandomIndex(exams)];
|
||||
const selectedModulePromises = await selectedModules.map(async (module: Module) => {
|
||||
const exams: Exam[] = await getExams(db, module, "false", undefined);
|
||||
const exam = exams[getRandomIndex(exams)];
|
||||
|
||||
if (exam) {
|
||||
return { module: exam.module, id: exam.id };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
if (exam) {
|
||||
return {module: exam.module, id: exam.id};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const exams = await Promise.all(selectedModulePromises);
|
||||
const examesFiltered = exams.filter((x) => x !== null) as ExamWithUser[];
|
||||
return flatten(
|
||||
assignees.map((assignee) =>
|
||||
examesFiltered.map((exam) => ({ ...exam, assignee }))
|
||||
)
|
||||
);
|
||||
const exams = await Promise.all(selectedModulePromises);
|
||||
const examesFiltered = exams.filter((x) => x !== null) as ExamWithUser[];
|
||||
return flatten(assignees.map((assignee) => examesFiltered.map((exam) => ({...exam, assignee}))));
|
||||
};
|
||||
|
||||
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||
const {
|
||||
selectedModules,
|
||||
assignees,
|
||||
// Generarte multiple true would generate an unique exam for eacah user
|
||||
// false would generate the same exam for all usersa
|
||||
generateMultiple = false,
|
||||
...body
|
||||
} = req.body as {
|
||||
selectedModules: Module[];
|
||||
assignees: string[];
|
||||
generateMultiple: Boolean;
|
||||
};
|
||||
const {
|
||||
selectedModules,
|
||||
assignees,
|
||||
// Generate multiple true would generate an unique exam for each user
|
||||
// false would generate the same exam for all users
|
||||
generateMultiple = false,
|
||||
...body
|
||||
} = req.body as {
|
||||
selectedModules: Module[];
|
||||
assignees: string[];
|
||||
generateMultiple: Boolean;
|
||||
name: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
|
||||
const exams: ExamWithUser[] = await generateExams(
|
||||
generateMultiple,
|
||||
selectedModules,
|
||||
assignees
|
||||
);
|
||||
if (exams.length === 0) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ ok: false, error: "No exams found for the selected modules" });
|
||||
return;
|
||||
}
|
||||
const exams: ExamWithUser[] = await generateExams(generateMultiple, selectedModules, assignees);
|
||||
|
||||
await setDoc(doc(db, "assignments", uuidv4()), {
|
||||
assigner: req.session.user?.id,
|
||||
assignees,
|
||||
results: [],
|
||||
exams,
|
||||
...body,
|
||||
});
|
||||
if (exams.length === 0) {
|
||||
res.status(400).json({ok: false, error: "No exams found for the selected modules"});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ ok: true });
|
||||
await setDoc(doc(db, "assignments", uuidv4()), {
|
||||
assigner: req.session.user?.id,
|
||||
assignees,
|
||||
results: [],
|
||||
exams,
|
||||
...body,
|
||||
});
|
||||
|
||||
res.status(200).json({ok: true});
|
||||
|
||||
for (const assigneeID of assignees) {
|
||||
const assigneeSnapshot = await getDoc(doc(db, "users", assigneeID));
|
||||
if (!assigneeSnapshot.exists()) continue;
|
||||
|
||||
const assignee = {id: assigneeID, ...assigneeSnapshot.data()} as User;
|
||||
const name = body.name;
|
||||
const teacher = req.session.user!;
|
||||
const examModulesLabel = exams.map((x) => capitalize(x.module)).join(", ");
|
||||
const startDate = moment(body.startDate).format("DD/MM/YYYY - HH:mm");
|
||||
const endDate = moment(body.endDate).format("DD/MM/YYYY - HH:mm");
|
||||
|
||||
await sendEmail(
|
||||
"assignment",
|
||||
{user: {name: assignee.name}, assignment: {name, startDate, endDate, modules: examModulesLabel, assigner: teacher.name}},
|
||||
[assignee.email],
|
||||
"EnCoach - New Assignment!",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {getAuth as getAdminAuth, UserRecord} from "firebase-admin/auth";
|
||||
import {app, adminApp} from "@/firebase";
|
||||
import {sessionOptions} from "@/lib/session";
|
||||
import {withIronSessionApiRoute} from "iron-session/next";
|
||||
import {prepareMailer, prepareMailOptions} from "@/email";
|
||||
import {prepareMailer, prepareMailOptions, sendEmail} from "@/email";
|
||||
import ShortUniqueId from "short-unique-id";
|
||||
|
||||
export default withIronSessionApiRoute(sendVerification, sessionOptions);
|
||||
@@ -13,8 +13,8 @@ async function sendVerification(req: NextApiRequest, res: NextApiResponse) {
|
||||
const short = new ShortUniqueId();
|
||||
|
||||
if (req.session.user) {
|
||||
const transport = prepareMailer("verification");
|
||||
const mailOptions = prepareMailOptions(
|
||||
const ok = await sendEmail(
|
||||
"verification",
|
||||
{
|
||||
name: req.session.user.name,
|
||||
code: short.randomUUID(6),
|
||||
@@ -22,12 +22,9 @@ async function sendVerification(req: NextApiRequest, res: NextApiResponse) {
|
||||
},
|
||||
[req.session.user.email],
|
||||
"EnCoach Verification",
|
||||
"verification",
|
||||
);
|
||||
|
||||
await transport.sendMail(mailOptions);
|
||||
|
||||
res.status(200).json({ok: true});
|
||||
res.status(200).json({ok});
|
||||
return;
|
||||
}
|
||||
res.status(404).json({ok: false});
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
getRadialProgressPNG,
|
||||
streamToBuffer,
|
||||
} from "@/utils/pdf";
|
||||
import moment from "moment-timezone";
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
@@ -308,7 +309,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
const pdfStream = await ReactPDF.renderToStream(
|
||||
<TestReport
|
||||
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}
|
||||
email={user.email}
|
||||
id={user.id}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import {User} from "@/interfaces/user";
|
||||
import {toast, ToastContainer} from "react-toastify";
|
||||
import axios from "axios";
|
||||
import {FormEvent, useState} from "react";
|
||||
import {FormEvent, useEffect, useState} from "react";
|
||||
import Head from "next/head";
|
||||
import useUser from "@/hooks/useUser";
|
||||
import {Divider} from "primereact/divider";
|
||||
@@ -13,9 +13,38 @@ import Input from "@/components/Low/Input";
|
||||
import clsx from "clsx";
|
||||
import {useRouter} from "next/router";
|
||||
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);
|
||||
|
||||
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() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -29,6 +58,10 @@ export default function Login() {
|
||||
redirectIfFound: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user && user.isVerified) router.push("/");
|
||||
}, [router, user]);
|
||||
|
||||
const forgotPassword = () => {
|
||||
if (!email || email.length < 0 || !EMAIL_REGEX.test(email)) {
|
||||
toast.error("Please enter your e-mail to reset your password!", {toastId: "forgot-invalid-email"});
|
||||
|
||||
@@ -11,13 +11,12 @@ import Button from "@/components/Low/Button";
|
||||
import Link from "next/link";
|
||||
import axios from "axios";
|
||||
import {ErrorMessage} from "@/constants/errors";
|
||||
import {RadioGroup} from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import {CorporateUser, EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "@/interfaces/user";
|
||||
import CountrySelect from "@/components/Low/CountrySelect";
|
||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||
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 useGroups from "@/hooks/useGroups";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
@@ -25,7 +24,7 @@ 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}) => {
|
||||
const user = req.session.user;
|
||||
|
||||
@@ -83,7 +82,7 @@ function UserProfile({user, mutateUser}: Props) {
|
||||
const [commercialRegistration, setCommercialRegistration] = useState<string | undefined>(
|
||||
user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
|
||||
);
|
||||
|
||||
const [timezone, setTimezone] = useState<string>(user.demographicInformation?.timezone || "UTC");
|
||||
const {groups} = useGroups();
|
||||
const {users} = useUsers();
|
||||
|
||||
@@ -146,6 +145,7 @@ function UserProfile({user, mutateUser}: Props) {
|
||||
position: user?.type === "corporate" ? position : undefined,
|
||||
gender,
|
||||
passport_id,
|
||||
timezone,
|
||||
},
|
||||
...(user.type === "corporate" ? {corporateInformation} : {}),
|
||||
});
|
||||
@@ -247,6 +247,13 @@ function UserProfile({user, mutateUser}: Props) {
|
||||
</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 (
|
||||
<Layout user={user}>
|
||||
<section className="w-full flex flex-col gap-4 md:gap-8 px-4 py-8">
|
||||
@@ -286,18 +293,6 @@ function UserProfile({user, mutateUser}: Props) {
|
||||
/>
|
||||
</DoubleColumnRow>
|
||||
<PasswordInput />
|
||||
|
||||
{user.type === "student" && (
|
||||
<Input
|
||||
type="text"
|
||||
name="passport_id"
|
||||
label="Passport/National ID"
|
||||
onChange={(e) => setPassportID(e)}
|
||||
placeholder="Enter National ID or Passport number"
|
||||
value={passport_id}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
{user.type === "agent" && <AgentInformationInput />}
|
||||
|
||||
<DoubleColumnRow>
|
||||
@@ -305,6 +300,23 @@ function UserProfile({user, mutateUser}: Props) {
|
||||
<PhoneInput />
|
||||
</DoubleColumnRow>
|
||||
|
||||
{user.type === "student" ? (
|
||||
<DoubleColumnRow>
|
||||
<Input
|
||||
type="text"
|
||||
name="passport_id"
|
||||
label="Passport/National ID"
|
||||
onChange={(e) => setPassportID(e)}
|
||||
placeholder="Enter National ID or Passport number"
|
||||
value={passport_id}
|
||||
required
|
||||
/>
|
||||
<TimezoneInput />
|
||||
</DoubleColumnRow>
|
||||
) : (
|
||||
<TimezoneInput />
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{user.type === "corporate" && (
|
||||
|
||||
@@ -139,9 +139,9 @@ export default function Stats() {
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
const calculateTotalScore = (stats: Stat[]) => {
|
||||
const calculateTotalScore = (stats: Stat[], divisionFactor: number) => {
|
||||
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) => {
|
||||
@@ -278,7 +278,10 @@ export default function Stats() {
|
||||
</span>
|
||||
<span className="px-2">
|
||||
Level{" "}
|
||||
{calculateTotalScore(stats.filter((s) => timestampToMoment(s).isBefore(date))).toFixed(1)}
|
||||
{calculateTotalScore(
|
||||
stats.filter((s) => timestampToMoment(s).isBefore(date)),
|
||||
5,
|
||||
).toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
) : null;
|
||||
@@ -364,6 +367,7 @@ export default function Stats() {
|
||||
return date.isValid()
|
||||
? calculateTotalScore(
|
||||
stats.filter((s) => timestampToMoment(s).isBefore(date)),
|
||||
5,
|
||||
).toFixed(1)
|
||||
: undefined;
|
||||
})
|
||||
@@ -599,188 +603,47 @@ export default function Stats() {
|
||||
}}
|
||||
/>
|
||||
<div className="flex -md:flex-col -md:items-center gap-4 flex-wrap">
|
||||
{/* Reading 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">Reading Score Band in Interval</span>
|
||||
<Chart
|
||||
options={{
|
||||
scales: {
|
||||
y: {
|
||||
min: 0,
|
||||
max: 9,
|
||||
{/* Module Score Band in Interval */}
|
||||
{MODULE_ARRAY.map((module, index) => (
|
||||
<div
|
||||
className="w-full md:max-w-2xl border border-mti-gray-platinum p-4 pb-12 rounded-xl h-fit md:h-96"
|
||||
key={module}>
|
||||
<span className="text-sm font-bold">{capitalize(module)} 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: "Reading",
|
||||
fill: false,
|
||||
borderColor: COLORS[0],
|
||||
backgroundColor: COLORS[0],
|
||||
borderWidth: 2,
|
||||
spanGaps: true,
|
||||
data: intervalDates.map((date) => {
|
||||
return calculateTotalScore(
|
||||
stats.filter(
|
||||
(s) => timestampToMoment(s).isBefore(date) && s.module === "reading",
|
||||
),
|
||||
).toFixed(1);
|
||||
}),
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
}}
|
||||
type="line"
|
||||
data={{
|
||||
labels: intervalDates.map((date) => moment(date).format("DD/MM/YYYY")),
|
||||
datasets: [
|
||||
{
|
||||
type: "line",
|
||||
label: capitalize(module),
|
||||
fill: false,
|
||||
borderColor: COLORS[index],
|
||||
backgroundColor: COLORS[index],
|
||||
borderWidth: 2,
|
||||
spanGaps: true,
|
||||
data: intervalDates.map((date) => {
|
||||
return calculateTotalScore(
|
||||
stats.filter(
|
||||
(s) => timestampToMoment(s).isBefore(date) && s.module === module,
|
||||
),
|
||||
1,
|
||||
).toFixed(1);
|
||||
}),
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -13,6 +13,6 @@ 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));
|
||||
return admins.map((x) => x?.type).includes("corporate") ? (admins[0] as CorporateUser) : undefined;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Module} from "@/interfaces";
|
||||
import { LevelScore } from "@/constants/ielts";
|
||||
import {LevelScore} from "@/constants/ielts";
|
||||
|
||||
type Type = "academic" | "general";
|
||||
|
||||
@@ -96,7 +96,7 @@ const academicMarking: {[key: number]: number} = {
|
||||
|
||||
const levelMarking: {[key: number]: number} = {
|
||||
88: 9, // Advanced
|
||||
64: 8 , // Upper-Intermediate
|
||||
64: 8, // Upper-Intermediate
|
||||
52: 6, // Intermediate
|
||||
32: 4, // Pre-Intermediate
|
||||
16: 2, // Elementary
|
||||
@@ -142,23 +142,24 @@ export const calculateBandScore = (correct: number, total: number, module: Modul
|
||||
};
|
||||
|
||||
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) => {
|
||||
switch(level) {
|
||||
switch (level) {
|
||||
case 0:
|
||||
return ['Beginner', 'Low A1'];
|
||||
return ["Beginner", "Low A1"];
|
||||
case 2:
|
||||
return ['Elementary', 'High A1/Low A2'];
|
||||
return ["Elementary", "High A1/Low A2"];
|
||||
case 4:
|
||||
return ['Pre-Intermediate', 'High A2/Low B1'];
|
||||
return ["Pre-Intermediate", "High A2/Low B1"];
|
||||
case 6:
|
||||
return ['Intermediate', 'High B1/Low B2'];
|
||||
return ["Intermediate", "High B1/Low B2"];
|
||||
case 8:
|
||||
return ['Upper-Intermediate', 'High B2/Low C1'];
|
||||
return ["Upper-Intermediate", "High B2/Low C1"];
|
||||
case 9:
|
||||
return ['Advanced', 'C1'];
|
||||
default: return [];
|
||||
return ["Advanced", "C1"];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
163
yarn.lock
163
yarn.lock
@@ -10,7 +10,7 @@
|
||||
"@babel/highlight" "^7.22.13"
|
||||
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"
|
||||
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==
|
||||
@@ -62,6 +62,13 @@
|
||||
dependencies:
|
||||
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":
|
||||
version "7.23.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.0.tgz#8c1f020c9df0e737e4e247c0619f58c68458aaeb"
|
||||
@@ -98,6 +105,16 @@
|
||||
source-map "^0.5.7"
|
||||
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":
|
||||
version "11.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-11.11.0.tgz#809b33ee6b1cb1a625fef7a45bc568ccd9b8f3ff"
|
||||
@@ -109,6 +126,11 @@
|
||||
"@emotion/weak-memoize" "^0.3.1"
|
||||
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":
|
||||
version "0.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.9.1.tgz#4ffb0055f7ef676ebc3a5a91fb621393294e2f43"
|
||||
@@ -145,6 +167,17 @@
|
||||
"@emotion/weak-memoize" "^0.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":
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-1.1.2.tgz#017a6e4c9b8a803bd576ff3d52a0ea6fa5a62b51"
|
||||
@@ -156,11 +189,26 @@
|
||||
"@emotion/utils" "^1.2.1"
|
||||
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":
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-1.2.2.tgz#d58e788ee27267a14342303e1abb3d508b6d0fec"
|
||||
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":
|
||||
version "0.8.1"
|
||||
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"
|
||||
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":
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-1.2.1.tgz#bbab58465738d31ae4cb3dbb6fc00a5991f755e4"
|
||||
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":
|
||||
version "0.3.1"
|
||||
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:
|
||||
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:
|
||||
version "3.1.0"
|
||||
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"
|
||||
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:
|
||||
version "1.0.2"
|
||||
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"
|
||||
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:
|
||||
version "7.1.0"
|
||||
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"
|
||||
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:
|
||||
version "3.1.8"
|
||||
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"
|
||||
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:
|
||||
version "3.1.1"
|
||||
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"
|
||||
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:
|
||||
version "1.0.3"
|
||||
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"
|
||||
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:
|
||||
version "1.0.3"
|
||||
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"
|
||||
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"
|
||||
resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz"
|
||||
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"
|
||||
integrity sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==
|
||||
|
||||
memoize-one@^5.1.1:
|
||||
memoize-one@^5.0.4, memoize-one@^5.1.1:
|
||||
version "5.2.1"
|
||||
resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz"
|
||||
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"
|
||||
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:
|
||||
version "2.29.4"
|
||||
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-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:
|
||||
version "18.2.0"
|
||||
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"
|
||||
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:
|
||||
version "1.22.6"
|
||||
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"
|
||||
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"
|
||||
resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz"
|
||||
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
|
||||
|
||||
Reference in New Issue
Block a user