Merge remote-tracking branch 'origin/develop' into feature/training-content

This commit is contained in:
Carlos Mesquita
2024-08-28 13:32:21 +01:00
21 changed files with 331 additions and 139 deletions

View File

@@ -11,14 +11,16 @@ interface Props {
export default function Checkbox({isChecked, onChange, children, disabled}: Props) {
return (
<div className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer" onClick={() => {
if(disabled) return;
<div
className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer"
onClick={() => {
if (disabled) return;
onChange(!isChecked);
}}>
<input type="checkbox" className="hidden" />
<div
className={clsx(
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
"w-6 h-6 min-w-6 min-h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
"transition duration-300 ease-in-out",
isChecked && "!bg-mti-purple-light ",
)}>

View File

@@ -4,17 +4,17 @@ import clsx from "clsx";
import {Stat, User} from "@/interfaces/user";
import {Module} from "@/interfaces";
import ai_usage from "@/utils/ai.detection";
import { calculateBandScore } from "@/utils/score";
import moment from 'moment';
import { Assignment } from '@/interfaces/results';
import { uuidv4 } from "@firebase/util";
import { useRouter } from "next/router";
import { uniqBy } from "lodash";
import { sortByModule } from "@/utils/moduleUtils";
import { convertToUserSolutions } from "@/utils/stats";
import { getExamById } from "@/utils/exams";
import { Exam, UserSolution } from '@/interfaces/exam';
import ModuleBadge from '../ModuleBadge';
import {calculateBandScore} from "@/utils/score";
import moment from "moment";
import {Assignment} from "@/interfaces/results";
import {uuidv4} from "@firebase/util";
import {useRouter} from "next/router";
import {uniqBy} from "lodash";
import {sortByModule} from "@/utils/moduleUtils";
import {convertToUserSolutions} from "@/utils/stats";
import {getExamById} from "@/utils/exams";
import {Exam, UserSolution} from "@/interfaces/exam";
import ModuleBadge from "../ModuleBadge";
const formatTimestamp = (timestamp: string | number) => {
const time = typeof timestamp === "string" ? parseInt(timestamp) : timestamp;
@@ -262,7 +262,7 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
key={uuidv4()}
className={clsx(
"flex flex-col justify-between gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden",
isDisabled && "grayscale tooltip",
(isDisabled || (!!assignment && !assignment.released)) && "grayscale tooltip",
correct / total >= 0.7 && "hover:border-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
correct / total < 0.3 && "hover:border-mti-rose",
@@ -276,7 +276,7 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
...(width !== undefined && {width}),
...(height !== undefined && {height}),
}}
data-tip="This exam is still being evaluated..."
data-tip={isDisabled ? "This exam is still being evaluated..." : "This exam is still locked by its assigner..."}
role="button">
{content}
</div>

View File

@@ -62,6 +62,30 @@ export default function AssignmentCard({
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / results.length;
};
const uniqModules = uniqBy(exams, (x) => x.module);
const shouldRenderPDF = () => {
if(released && allowDownload) {
// in order to be downloadable, the assignment has to be released
// the component should have the allowDownload prop
// and the assignment should not have the level module
return uniqModules.every(({ module }) => module !== 'level');
}
return false;
}
const shouldRenderExcel = () => {
if(released && allowExcelDownload) {
// in order to be downloadable, the assignment has to be released
// the component should have the allowExcelDownload prop
// and the assignment should have the level module
return uniqModules.some(({ module }) => module === 'level');
}
return false;
}
return (
<div
onClick={onClick}
@@ -70,8 +94,8 @@ export default function AssignmentCard({
<div className="flex flex-row justify-between">
<h3 className="text-xl font-semibold">{name}</h3>
<div className="flex gap-2">
{allowDownload && released && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
{allowExcelDownload && released && renderExcelIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
{shouldRenderPDF() && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
{shouldRenderExcel() && renderExcelIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
{allowArchive && !archived && renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
{allowUnarchive && archived && renderUnarchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
{!released && renderReleaseIcon("text-mti-gray-dim", "text-mti-gray-dim")}
@@ -94,7 +118,7 @@ export default function AssignmentCard({
<span>Assigner: {getUserName(users.find((x) => x.id === assigner))}</span>
</div>
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
{uniqBy(exams, (x) => x.module).map(({module}) => (
{uniqModules.map(({module}) => (
<div
key={module}
className={clsx(

View File

@@ -44,6 +44,20 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
.finally(onClose);
};
const startAssignment = () => {
if (assignment) {
axios
.post(`/api/assignments/${assignment.id}/start`)
.then(() => {
toast.success(`The assignment "${assignment.name}" has been started successfully!`);
})
.catch((e) => {
console.log(e);
toast.error("Something went wrong, please try again later!");
});
}
};
const formatTimestamp = (timestamp: string) => {
const date = moment(parseInt(timestamp));
const formatter = "YYYY/MM/DD - HH:mm";
@@ -301,6 +315,11 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
Delete
</Button>
)}
{assignment && (assignment.results.length === 0 || moment().isAfter(moment(assignment.startDate))) && (
<Button variant="outline" color="green" className="w-full max-w-[200px]" onClick={startAssignment}>
Start
</Button>
)}
<Button onClick={onClose} className="w-full max-w-[200px]">
Close
</Button>

View File

@@ -11,6 +11,7 @@ import {useRouter} from "next/router";
import {Fragment, useEffect, useState} from "react";
import {
BsArrowCounterclockwise,
BsBan,
BsBook,
BsClipboard,
BsClipboardFill,
@@ -27,6 +28,7 @@ import Modal from "@/components/Modal";
import {UserSolution} from "@/interfaces/exam";
import ai_usage from "@/utils/ai.detection";
import useGradingSystem from "@/hooks/useGrading";
import {Assignment} from "@/interfaces/results";
interface Score {
module: Module;
@@ -45,10 +47,11 @@ interface Props {
};
solutions: UserSolution[];
isLoading: boolean;
assignment?: Assignment;
onViewResults: (moduleIndex?: number) => void;
}
export default function Finish({user, scores, modules, information, solutions, isLoading, onViewResults}: Props) {
export default function Finish({user, scores, modules, information, solutions, isLoading, assignment, onViewResults}: Props) {
const [selectedModule, setSelectedModule] = useState(modules[0]);
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
const [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false);
@@ -209,7 +212,18 @@ export default function Finish({user, scores, modules, information, solutions, i
</span>
</div>
)}
{!isLoading && (
{assignment && !assignment.released && (
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-12">
{/* <span className={clsx("loading loading-infinity w-32", moduleColors[selectedModule].progress)} /> */}
<BsBan size={64} className={clsx(moduleColors[selectedModule].progress)} />
<span className={clsx("text-center text-2xl font-bold", moduleColors[selectedModule].progress)}>
This exam has not yet been released by its assigner.
<br />
You can check it later on your records page when it is released!
</span>
</div>
)}
{!isLoading && false && (
<div className="mb-20 mt-32 flex w-full items-center justify-between gap-9">
<span className="max-w-3xl">{moduleResultText(selectedModule, bandScore)}</span>
<div className="flex gap-9 px-16">

View File

@@ -1,4 +1,4 @@
import { Module } from ".";
import {Module} from ".";
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
export type Variant = "full" | "partial";
@@ -15,6 +15,7 @@ interface ExamBase {
shuffle?: boolean;
createdBy?: string; // option as it has been added later
createdAt?: string; // option as it has been added later
private?: boolean;
}
export interface ReadingExam extends ExamBase {
module: "reading";
@@ -67,7 +68,7 @@ export interface UserSolution {
};
exercise: string;
isDisabled?: boolean;
shuffleMaps?: ShuffleMap[]
shuffleMaps?: ShuffleMap[];
}
export interface WritingExam extends ExamBase {
@@ -99,24 +100,19 @@ export type Exercise =
export interface Evaluation {
comment: string;
overall: number;
task_response: { [key: string]: number | { grade: number; comment: string } };
misspelled_pairs?: { correction: string | null; misspelled: string }[];
task_response: {[key: string]: number | {grade: number; comment: string}};
misspelled_pairs?: {correction: string | null; misspelled: string}[];
}
type InteractivePerfectAnswerKey = `perfect_answer_${number}`;
type InteractiveTranscriptKey = `transcript_${number}`;
type InteractiveFixedTextKey = `fixed_text_${number}`;
type InteractivePerfectAnswerType = { [key in InteractivePerfectAnswerKey]: { answer: string } };
type InteractiveTranscriptType = { [key in InteractiveTranscriptKey]?: string };
type InteractiveFixedTextType = { [key in InteractiveFixedTextKey]?: string };
interface InteractiveSpeakingEvaluation extends Evaluation,
InteractivePerfectAnswerType,
InteractiveTranscriptType,
InteractiveFixedTextType { }
type InteractivePerfectAnswerType = {[key in InteractivePerfectAnswerKey]: {answer: string}};
type InteractiveTranscriptType = {[key in InteractiveTranscriptKey]?: string};
type InteractiveFixedTextType = {[key in InteractiveFixedTextKey]?: string};
interface InteractiveSpeakingEvaluation extends Evaluation, InteractivePerfectAnswerType, InteractiveTranscriptType, InteractiveFixedTextType {}
interface SpeakingEvaluation extends CommonEvaluation {
perfect_answer_1?: string;
@@ -189,10 +185,10 @@ export interface InteractiveSpeakingExercise {
first_title?: string;
second_title?: string;
text: string;
prompts: { text: string; video_url: string }[];
prompts: {text: string; video_url: string}[];
userSolutions: {
id: string;
solution: { questionIndex: number; question: string; answer: string }[];
solution: {questionIndex: number; question: string; answer: string}[];
evaluation?: InteractiveSpeakingEvaluation;
}[];
topic?: string;
@@ -208,14 +204,14 @@ export interface FillBlanksMCOption {
B: string;
C: string;
D: string;
}
};
}
export interface FillBlanksExercise {
prompt: string; // *EXAMPLE: "Complete the summary below. Click a blank to select the corresponding word for it."
type: "fillBlanks";
id: string;
words: (string | { letter: string; word: string } | FillBlanksMCOption)[]; // *EXAMPLE: ["preserve", "unaware"]
words: (string | {letter: string; word: string} | FillBlanksMCOption)[]; // *EXAMPLE: ["preserve", "unaware"]
text: string; // *EXAMPLE: "They tried to {{1}} burning"
allowRepetition?: boolean;
solutions: {
@@ -234,7 +230,7 @@ export interface TrueFalseExercise {
id: string;
prompt: string; // *EXAMPLE: "Select the appropriate option."
questions: TrueFalseQuestion[];
userSolutions: { id: string; solution: "true" | "false" | "not_given" }[];
userSolutions: {id: string; solution: "true" | "false" | "not_given"}[];
}
export interface TrueFalseQuestion {
@@ -263,7 +259,7 @@ export interface MatchSentencesExercise {
type: "matchSentences";
id: string;
prompt: string;
userSolutions: { question: string; option: string }[];
userSolutions: {question: string; option: string}[];
sentences: MatchSentenceExerciseSentence[];
allowRepetition: boolean;
options: MatchSentenceExerciseOption[];
@@ -286,7 +282,7 @@ export interface MultipleChoiceExercise {
id: string;
prompt: string; // *EXAMPLE: "Select the appropriate option."
questions: MultipleChoiceQuestion[];
userSolutions: { question: string; option: string }[];
userSolutions: {question: string; option: string}[];
}
export interface MultipleChoiceQuestion {
@@ -306,10 +302,10 @@ export interface ShuffleMap {
questionID: string;
map: {
[key: string]: string;
}
};
}
export interface Shuffles {
exerciseID: string;
shuffles: ShuffleMap[]
shuffles: ShuffleMap[];
}

View File

@@ -33,4 +33,4 @@ export interface Assignment {
start?: boolean;
}
export type AssignmentWithCorporateId = Assignment & { corporateId: string };
export type AssignmentWithCorporateId = Assignment & {corporateId: string};

View File

@@ -13,7 +13,7 @@ import axios from "axios";
import clsx from "clsx";
import {capitalize} from "lodash";
import {useRouter} from "next/router";
import {BsCheck, BsTrash, BsUpload} from "react-icons/bs";
import {BsBan, BsBanFill, BsCheck, BsCircle, BsStop, BsTrash, BsUpload, BsX} from "react-icons/bs";
import {toast} from "react-toastify";
import {useListSearch} from "@/hooks/useListSearch";
@@ -72,6 +72,28 @@ export default function ExamList({user}: {user: User}) {
router.push("/exercises");
};
const privatizeExam = async (exam: Exam) => {
if (!confirm(`Are you sure you want to make this ${capitalize(exam.module)} exam ${exam.private ? "public" : "private"}?`)) return;
axios
.patch(`/api/exam/${exam.module}/${exam.id}`, {private: !exam.private})
.then(() => toast.success(`Updated the "${exam.id}" exam`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Exam not found!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to update this exam!");
return;
}
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
};
const deleteExam = async (exam: Exam) => {
if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return;
@@ -119,6 +141,10 @@ export default function ExamList({user}: {user: User}) {
header: "Timer",
cell: (info) => <>{info.getValue()} minute(s)</>,
}),
columnHelper.accessor("private", {
header: "Private",
cell: (info) => <span className="w-full flex items-center justify-center">{!info.getValue() ? <BsX /> : <BsCheck />}</span>,
}),
columnHelper.accessor("createdAt", {
header: "Created At",
cell: (info) => {
@@ -140,12 +166,18 @@ export default function ExamList({user}: {user: User}) {
cell: ({row}: {row: {original: Exam}}) => {
return (
<div className="flex gap-4">
<div
<button
data-tip={row.original.private ? "Set as public" : "Set as private"}
onClick={async () => await privatizeExam(row.original)}
className="cursor-pointer tooltip">
{row.original.private ? <BsCircle /> : <BsBan />}
</button>
<button
data-tip="Load exam"
className="cursor-pointer tooltip"
onClick={async () => await loadExam(row.original.module, row.original.id)}>
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
</button>
{PERMISSIONS.examManagement.delete.includes(user.type) && (
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteExam(row.original)}>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />

View File

@@ -459,6 +459,7 @@ export default function ExamPage({page}: Props) {
user={user!}
modules={selectedModules}
solutions={userSolutions}
assignment={assignment}
information={{
timeSpent,
inactivity: totalInactivity,

View File

@@ -1,6 +1,7 @@
import FillBlanksEdit from "@/components/Generation/fill.blanks.edit";
import MultipleChoiceEdit from "@/components/Generation/multiple.choice.edit";
import WriteBlankEdits from "@/components/Generation/write.blanks.edit";
import Checkbox from "@/components/Low/Checkbox";
import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select";
import {
@@ -234,7 +235,7 @@ interface Props {
id: string;
}
const LevelGeneration = ({ id } : Props) => {
const LevelGeneration = ({id}: Props) => {
const [generatedExam, setGeneratedExam] = useState<LevelExam>();
const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<LevelExam>();
@@ -242,6 +243,7 @@ const LevelGeneration = ({ id } : Props) => {
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
const [numberOfParts, setNumberOfParts] = useState(1);
const [parts, setParts] = useState<LevelSection[]>([{quantity: 10, type: "multiple_choice_4"}]);
const [isPrivate, setPrivate] = useState<boolean>(false);
useEffect(() => {
setParts((prev) => Array.from(Array(numberOfParts)).map((_, i) => (!!prev.at(i) ? prev.at(i)! : {quantity: 10, type: "multiple_choice_4"})));
@@ -301,6 +303,7 @@ const LevelGeneration = ({ id } : Props) => {
difficulty,
variant: "full",
isDiagnostic: false,
private: isPrivate,
parts: parts
.map((part, index) => {
const currentExercise = result.data.exercises[`exercise_${index + 1}`] as any;
@@ -424,7 +427,7 @@ const LevelGeneration = ({ id } : Props) => {
return;
}
if(!id) {
if (!id) {
toast.error("Please insert a title before submitting");
return;
}
@@ -456,8 +459,8 @@ const LevelGeneration = ({ id } : Props) => {
return (
<>
<div className="flex gap-4 w-full">
<div className="flex flex-col gap-3 w-full">
<div className="flex gap-4 w-full items-center">
<div className="flex flex-col gap-3 w-1/2">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
options={DIFFICULTIES.map((x) => ({
@@ -468,14 +471,20 @@ const LevelGeneration = ({ id } : Props) => {
value={{value: difficulty, label: capitalize(difficulty)}}
/>
</div>
<div className="flex flex-col gap-3 w-full">
<div className="flex flex-col gap-3 w-1/3">
<label className="font-normal text-base text-mti-gray-dim">Number of Parts</label>
<Input type="number" name="Number of Parts" onChange={(v) => setNumberOfParts(parseInt(v))} value={numberOfParts} />
</div>
<div className="flex flex-col gap-3 w-full">
<div className="flex flex-col gap-3 w-1/3">
<label className="font-normal text-base text-mti-gray-dim">Timer (in minutes)</label>
<Input type="number" name="Timer (in minutes)" onChange={(v) => setTimer(parseInt(v))} value={timer} />
</div>
<div className="flex flex-col gap-3 w-fit h-fit">
<div className="h-6" />
<Checkbox isChecked={isPrivate} onChange={setPrivate}>
Privacy (Only available for Assignments)
</Checkbox>
</div>
</div>
<Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-level/20 p-1">

View File

@@ -16,6 +16,7 @@ import {BsArrowRepeat, BsCheck} from "react-icons/bs";
import {toast} from "react-toastify";
import WriteBlanksEdit from "@/components/Generation/write.blanks.edit";
import {generate} from "random-words";
import Checkbox from "@/components/Low/Checkbox";
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
@@ -232,7 +233,7 @@ interface Props {
id: string;
}
const ListeningGeneration = ({ id } : Props) => {
const ListeningGeneration = ({id}: Props) => {
const [part1, setPart1] = useState<ListeningPart>();
const [part2, setPart2] = useState<ListeningPart>();
const [part3, setPart3] = useState<ListeningPart>();
@@ -241,6 +242,7 @@ const ListeningGeneration = ({ id } : Props) => {
const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<ListeningExam>();
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
const [isPrivate, setPrivate] = useState<boolean>(false);
useEffect(() => {
const part1Timer = part1 ? 5 : 0;
@@ -262,7 +264,7 @@ const ListeningGeneration = ({ id } : Props) => {
console.log({parts});
if (parts.length === 0) return toast.error("Please generate at least one section!");
if(!id) {
if (!id) {
toast.error("Please insert a title before submitting");
return;
}
@@ -275,6 +277,7 @@ const ListeningGeneration = ({ id } : Props) => {
parts,
minTimer,
difficulty,
private: isPrivate,
})
.then((result) => {
playSound("sent");
@@ -313,7 +316,7 @@ const ListeningGeneration = ({ id } : Props) => {
return (
<>
<div className="flex gap-4 w-1/2">
<div className="flex gap-4 w-full items-center">
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input
@@ -324,7 +327,7 @@ const ListeningGeneration = ({ id } : Props) => {
className="max-w-[300px]"
/>
</div>
<div className="flex flex-col gap-3 w-full">
<div className="flex flex-col gap-3 w-1/2">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
options={DIFFICULTIES.map((x) => ({
@@ -336,6 +339,12 @@ const ListeningGeneration = ({ id } : Props) => {
disabled={!!part1 || !!part2 || !!part3 || !!part4}
/>
</div>
<div className="flex flex-col gap-3 w-fit h-fit">
<div className="h-6" />
<Checkbox isChecked={isPrivate} onChange={setPrivate}>
Privacy (Only available for Assignments)
</Checkbox>
</div>
</div>
<Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-listening/20 p-1">

View File

@@ -20,6 +20,7 @@ import TrueFalseEdit from "@/components/Generation/true.false.edit";
import WriteBlanksEdit from "@/components/Generation/write.blanks.edit";
import MatchSentencesEdit from "@/components/Generation/match.sentences.edit";
import MultipleChoiceEdit from "@/components/Generation/multiple.choice.edit";
import Checkbox from "@/components/Low/Checkbox";
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
@@ -262,7 +263,7 @@ interface Props {
id: string;
}
const ReadingGeneration = ({ id } : Props) => {
const ReadingGeneration = ({id}: Props) => {
const [part1, setPart1] = useState<ReadingPart>();
const [part2, setPart2] = useState<ReadingPart>();
const [part3, setPart3] = useState<ReadingPart>();
@@ -270,6 +271,7 @@ const ReadingGeneration = ({ id } : Props) => {
const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<ReadingExam>();
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
const [isPrivate, setPrivate] = useState<boolean>(false);
useEffect(() => {
const parts = [part1, part2, part3].filter((x) => !!x);
@@ -304,7 +306,7 @@ const ReadingGeneration = ({ id } : Props) => {
return;
}
if(!id) {
if (!id) {
toast.error("Please insert a title before submitting");
return;
}
@@ -319,6 +321,7 @@ const ReadingGeneration = ({ id } : Props) => {
type: "academic",
variant: parts.length === 3 ? "full" : "partial",
difficulty,
private: isPrivate,
};
axios
@@ -344,7 +347,7 @@ const ReadingGeneration = ({ id } : Props) => {
return (
<>
<div className="flex gap-4 w-1/2">
<div className="flex gap-4 w-full items-center">
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input
@@ -355,7 +358,7 @@ const ReadingGeneration = ({ id } : Props) => {
className="max-w-[300px]"
/>
</div>
<div className="flex flex-col gap-3 w-full">
<div className="flex flex-col gap-3 w-1/2">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
options={DIFFICULTIES.map((x) => ({
@@ -367,6 +370,12 @@ const ReadingGeneration = ({ id } : Props) => {
disabled={!!part1 || !!part2 || !!part3}
/>
</div>
<div className="flex flex-col gap-3 w-fit h-fit">
<div className="h-6" />
<Checkbox isChecked={isPrivate} onChange={setPrivate}>
Privacy (Only available for Assignments)
</Checkbox>
</div>
</div>
<Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-reading/20 p-1">

View File

@@ -1,3 +1,4 @@
import Checkbox from "@/components/Low/Checkbox";
import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select";
import {Difficulty, Exercise, InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise} from "@/interfaces/exam";
@@ -225,7 +226,7 @@ interface Props {
id: string;
}
const SpeakingGeneration = ({ id } : Props) => {
const SpeakingGeneration = ({id}: Props) => {
const [part1, setPart1] = useState<SpeakingPart>();
const [part2, setPart2] = useState<SpeakingPart>();
const [part3, setPart3] = useState<SpeakingPart>();
@@ -233,6 +234,7 @@ const SpeakingGeneration = ({ id } : Props) => {
const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<SpeakingExam>();
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
const [isPrivate, setPrivate] = useState<boolean>(false);
useEffect(() => {
const parts = [part1, part2, part3].filter((x) => !!x);
@@ -247,7 +249,7 @@ const SpeakingGeneration = ({ id } : Props) => {
const submitExam = () => {
if (!part1?.result && !part2?.result && !part3?.result) return toast.error("Please generate at least one task!");
if(!id) {
if (!id) {
toast.error("Please insert a title before submitting");
return;
}
@@ -272,6 +274,7 @@ const SpeakingGeneration = ({ id } : Props) => {
variant: minTimer >= 14 ? "full" : "partial",
module: "speaking",
instructorGender: genders.every((x) => x === "male") ? "male" : genders.every((x) => x === "female") ? "female" : "varied",
private: isPrivate,
};
axios
@@ -313,7 +316,7 @@ const SpeakingGeneration = ({ id } : Props) => {
return (
<>
<div className="flex gap-4 w-1/2">
<div className="flex gap-4 w-full items-center">
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input
@@ -324,7 +327,7 @@ const SpeakingGeneration = ({ id } : Props) => {
className="max-w-[300px]"
/>
</div>
<div className="flex flex-col gap-3 w-full">
<div className="flex flex-col gap-3 w-1/2">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
options={DIFFICULTIES.map((x) => ({
@@ -336,6 +339,13 @@ const SpeakingGeneration = ({ id } : Props) => {
disabled={!!part1 || !!part2 || !!part3}
/>
</div>
<div className="flex flex-col gap-3 w-fit h-fit">
<div className="h-6" />
<Checkbox isChecked={isPrivate} onChange={setPrivate}>
Privacy (Only available for Assignments)
</Checkbox>
</div>
</div>
<Tab.Group>

View File

@@ -1,3 +1,4 @@
import Checkbox from "@/components/Low/Checkbox";
import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select";
import {Difficulty, WritingExam, WritingExercise} from "@/interfaces/exam";
@@ -79,13 +80,14 @@ interface Props {
id: string;
}
const WritingGeneration = ({ id } : Props) => {
const WritingGeneration = ({id}: Props) => {
const [task1, setTask1] = useState<string>();
const [task2, setTask2] = useState<string>();
const [minTimer, setMinTimer] = useState(60);
const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<WritingExam>();
const [difficulty, setDifficulty] = useState<Difficulty>(sample(DIFFICULTIES)!);
const [isPrivate, setPrivate] = useState<boolean>(false);
useEffect(() => {
const task1Timer = task1 ? 20 : 0;
@@ -120,7 +122,7 @@ const WritingGeneration = ({ id } : Props) => {
return;
}
if(!id) {
if (!id) {
toast.error("Please insert a title before submitting");
return;
}
@@ -164,6 +166,7 @@ const WritingGeneration = ({ id } : Props) => {
id,
variant: exercise1 && exercise2 ? "full" : "partial",
difficulty,
private: isPrivate,
};
axios
@@ -188,7 +191,7 @@ const WritingGeneration = ({ id } : Props) => {
return (
<>
<div className="flex gap-4 w-1/2">
<div className="flex gap-4 w-full items-center">
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input
@@ -199,7 +202,7 @@ const WritingGeneration = ({ id } : Props) => {
className="max-w-[300px]"
/>
</div>
<div className="flex flex-col gap-3 w-full">
<div className="flex flex-col gap-3 w-1/2">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
options={DIFFICULTIES.map((x) => ({value: x, label: capitalize(x)}))}
@@ -208,6 +211,13 @@ const WritingGeneration = ({ id } : Props) => {
disabled={!!task1 || !!task2}
/>
</div>
<div className="flex flex-col gap-3 w-fit h-fit">
<div className="h-6" />
<Checkbox isChecked={isPrivate} onChange={setPrivate}>
Privacy (Only available for Assignments)
</Checkbox>
</div>
</div>
<Tab.Group>

View File

@@ -84,9 +84,16 @@ function commonExcel({
.map((assignee: string) => {
const userStats = allStats.filter((s: any) => s.user === assignee);
const dates = userStats.map((s: any) => moment(s.date));
const user = users.find((u) => u.id === assignee);
return {
userId: assignee,
user: users.find((u) => u.id === assignee),
// added some default values in case the user is not found
// could it be possible to have an assigned user deleted from the database?
user: user || {
name: "Unknown",
email: "Unknown",
demographicInformation: { passportId: "Unknown", gender: "Unknown" },
},
...userStats.reduce(
(acc: any, curr: any) => {
return {
@@ -152,7 +159,7 @@ function commonExcel({
});
// added empty arrays to force row spacings
const customTableAndLine = [[],...customTable, []];
const customTableAndLine = [[], ...customTable, []];
customTableAndLine.forEach((row: string[], index) => {
worksheet.addRow(row);
});
@@ -188,19 +195,24 @@ function commonExcel({
worksheet.addRow(tableColumnHeaders);
// 1 headers rows
const startIndexTable = firstSectionData.length + customTableAndLine.length + 1;
const startIndexTable =
firstSectionData.length + customTableAndLine.length + 1;
// // Merge "Test Sections" over dynamic number of columns
// const tableColumns = staticHeaders.length + numberOfTestSections;
// K10:M12 = 10,11,12,13
// horizontally group Test Sections
// if there are test section headers to even merge:
if (testSectionHeaders.length > 1) {
worksheet.mergeCells(
startIndexTable,
staticHeaders.length + 1,
startIndexTable,
tableColumnHeadersFirstPart.length
);
}
// Add the dynamic second and third header rows for test sections and sub-columns
worksheet.addRow([
@@ -229,7 +241,12 @@ function commonExcel({
// vertically group based on the part, exercise and type
staticHeaders.forEach((header, index) => {
worksheet.mergeCells(startIndexTable, index + 1, startIndexTable + 3, index + 1);
worksheet.mergeCells(
startIndexTable,
index + 1,
startIndexTable + 3,
index + 1
);
});
assigneesData.forEach((data, index) => {
@@ -316,13 +333,17 @@ async function mastercorporateAssignment(
const adminsData = await getSpecificUsers(adminUsers);
const companiesData = adminsData.map((user) => {
const name = getUserName(user);
const users = userGroupsParticipants
.filter((p) => data.assignees.includes(p));
const users = userGroupsParticipants.filter((p) =>
data.assignees.includes(p)
);
const stats = data.results
.flatMap((r: any) => r.stats)
.filter((s: any) => users.includes(s.user));
const correct = stats.reduce((acc: number, s: any) => acc + s.score.correct, 0);
const correct = stats.reduce(
(acc: number, s: any) => acc + s.score.correct,
0
);
const total = stats.reduce(
(acc: number, curr: any) => acc + curr.score.total,
0
@@ -342,9 +363,11 @@ async function mastercorporateAssignment(
correct: companiesData.reduce((acc, curr) => acc + curr.correct, 0),
total: companiesData.reduce((acc, curr) => acc + curr.total, 0),
},
].map((c) => [c.name, `${c.correct}/${c.total}`])
].map((c) => [c.name, `${c.correct}/${c.total}`]);
const customTableHeaders = [{ name: "Corporate", helper: (data: any) => data.user.corporateName}];
const customTableHeaders = [
{ name: "Corporate", helper: (data: any) => data.user.corporateName },
];
return commonExcel({
data,
userName: user.corporateInformation?.companyInformation?.name || "",
@@ -354,12 +377,13 @@ async function mastercorporateAssignment(
return {
...u,
corporateName: getUserName(admin),
}
};
}),
sectionName: "Master Corporate Name :",
customTable: [['Corporate Summary'], ...customTable],
customTable: [["Corporate Summary"], ...customTable],
customTableHeaders: customTableHeaders.map((h) => h.name),
renderCustomTableData: (data) => customTableHeaders.map((h) => h.helper(data)),
renderCustomTableData: (data) =>
customTableHeaders.map((h) => h.helper(data)),
});
}
@@ -411,7 +435,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
case "corporate":
return corporateAssignment(user as CorporateUser, data, users);
case "mastercorporate":
return mastercorporateAssignment(user as MasterCorporateUser, data, users);
return mastercorporateAssignment(
user as MasterCorporateUser,
data,
users
);
default:
throw new Error("Invalid user type");
}

View File

@@ -1,7 +1,7 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, doc, getDoc, deleteDoc} from "firebase/firestore";
import {getFirestore, doc, getDoc, deleteDoc, setDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {PERMISSIONS} from "@/constants/userPermissions";
@@ -12,6 +12,7 @@ export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res);
if (req.method === "PATCH") return patch(req, res);
if (req.method === "DELETE") return del(req, res);
}
@@ -37,6 +38,25 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
}
}
async function patch(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {module, id} = req.query as {module: string; id: string};
const docRef = doc(db, module, id);
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
await setDoc(docRef, req.body, {merge: true});
res.status(200).json({ok: true});
} else {
res.status(404).json({ok: false});
}
}
async function del(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});

View File

@@ -10,7 +10,7 @@ import {v4} from "uuid";
import {checkAccess} from "@/utils/permissions";
import {CEFR_STEPS} from "@/resources/grading";
import {getCorporateUser} from "@/resources/user";
import {getUserCorporate} from "@/utils/groups";
import {getUserCorporate} from "@/utils/groups.be";
import {Grading} from "@/interfaces";
import {getGroupsForUser} from "@/utils/groups.be";
import {uniq} from "lodash";

View File

@@ -1,18 +1,10 @@
import moment from "moment";
import { Assignment } from "@/interfaces/results";
import {Assignment} from "@/interfaces/results";
export const futureAssignmentFilter = (a: Assignment) =>
moment(a.startDate).isAfter(moment()) && !a.archived && !a.start;
export const futureAssignmentFilter = (a: Assignment) => moment(a.startDate).isAfter(moment()) && !a.archived;
export const pastAssignmentFilter = (a: Assignment) => moment(a.endDate).isBefore(moment()) && !a.archived;
export const pastAssignmentFilter = (a: Assignment) =>
(moment(a.endDate).isBefore(moment()) ||
a.assignees.length === a.results.length ||
(moment(a.startDate).isBefore(moment()) && !a.start)) &&
!a.archived;
export const archivedAssignmentFilter = (a: Assignment) => a.archived;
export const activeAssignmentFilter = (a: Assignment) =>
moment(a.endDate).isAfter(moment()) &&
// && moment(a.startDate).isBefore(moment())
a.start &&
a.assignees.length > a.results.length;
export const activeAssignmentFilter = (a: Assignment) => moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment());

View File

@@ -1,4 +1,4 @@
import {collection, getDocs, query, where, setDoc, doc, Firestore, getDoc} from "firebase/firestore";
import {collection, getDocs, query, where, setDoc, doc, Firestore, getDoc, and} from "firebase/firestore";
import {shuffle} from "lodash";
import {Difficulty, Exam, InstructorGender, SpeakingExam, Variant, WritingExam} from "@/interfaces/exam";
import {DeveloperUser, Stat, StudentUser, User} from "@/interfaces/user";
@@ -17,7 +17,7 @@ export const getExams = async (
): Promise<Exam[]> => {
const moduleRef = collection(db, module);
const q = query(moduleRef, where("isDiagnostic", "==", false));
const q = query(moduleRef, and(where("isDiagnostic", "==", false), where("private", "!=", true)));
const snapshot = await getDocs(q);
const allExams = shuffle(

View File

@@ -33,11 +33,34 @@ export const updateExpiryDateOnGroup = async (participantID: string, corporateID
return;
};
export const getUserCorporate = async (id: string) => {
const user = await getUser(id);
if (user.type === "corporate" || user.type === "mastercorporate") return user;
const groups = await getParticipantGroups(id);
const admins = await Promise.all(groups.map((x) => x.admin).map(getUser));
const corporates = admins.filter((x) => x.type === "corporate");
if (corporates.length === 0) return undefined;
return corporates.shift() as CorporateUser;
};
export const getGroups = async () => {
const groupDocs = await getDocs(collection(db, "groups"));
return groupDocs.docs.map((x) => ({...x.data(), id: x.id})) as Group[];
};
export const getParticipantGroups = async (id: string) => {
const snapshot = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", id)));
const groups = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Group[];
return groups;
};
export const getUserGroups = async (id: string): Promise<Group[]> => {
const groupDocs = await getDocs(query(collection(db, "groups"), where("admin", "==", id)));
return groupDocs.docs.map((x) => ({...x.data(), id})) as Group[];

View File

@@ -1,36 +1,30 @@
import { CorporateUser, Group, User, Type } from "@/interfaces/user";
import {CorporateUser, Group, User, Type} from "@/interfaces/user";
import axios from "axios";
export const isUserFromCorporate = async (userID: string) => {
const groups = (await axios.get<Group[]>(`/api/groups?participant=${userID}`))
.data;
const groups = (await axios.get<Group[]>(`/api/groups?participant=${userID}`)).data;
const users = (await axios.get<User[]>("/api/users/list")).data;
const adminTypes = groups.map(
(g) => users.find((u) => u.id === g.admin)?.type
);
const adminTypes = groups.map((g) => users.find((u) => u.id === g.admin)?.type);
return adminTypes.includes("corporate");
};
const getAdminForGroup = async (userID: string, role: Type) => {
const groups = (await axios.get<Group[]>(`/api/groups?participant=${userID}`))
.data;
const groups = (await axios.get<Group[]>(`/api/groups?participant=${userID}`)).data;
const adminRequests = await Promise.all(
groups.map(async (g) => {
const userRequest = await axios.get<User>(`/api/users/${g.admin}`);
if (userRequest.status === 200) return userRequest.data;
return undefined;
})
}),
);
const admins = adminRequests.filter((x) => x?.type === role);
return admins.length > 0 ? (admins[0] as CorporateUser) : undefined;
};
export const getUserCorporate = async (
userID: string
): Promise<CorporateUser | undefined> => {
export const getUserCorporate = async (userID: string): Promise<CorporateUser | undefined> => {
const userRequest = await axios.get<User>(`/api/users/${userID}`);
if (userRequest.status === 200) {
const user = userRequest.data;