Added option to start exam which serves as an alternative to start date for the exam

This commit is contained in:
Joao Ramos
2024-08-24 17:38:57 +01:00
parent 101605ad88
commit 74a53f55fd
8 changed files with 1850 additions and 1346 deletions

View File

@@ -1,24 +1,32 @@
import Input from "@/components/Low/Input";
import Modal from "@/components/Modal";
import {Module} from "@/interfaces";
import { Module } from "@/interfaces";
import clsx from "clsx";
import {useEffect, useState} from "react";
import {BsBook, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
import {generate} from "random-words";
import {capitalize} from "lodash";
import { useEffect, useState } from "react";
import {
BsBook,
BsCheckCircle,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
BsXCircle,
} from "react-icons/bs";
import { generate } from "random-words";
import { capitalize } from "lodash";
import useUsers from "@/hooks/useUsers";
import {Group, User} from "@/interfaces/user";
import { Group, User } from "@/interfaces/user";
import ProgressBar from "@/components/Low/ProgressBar";
import {calculateAverageLevel} from "@/utils/score";
import { calculateAverageLevel } from "@/utils/score";
import Button from "@/components/Low/Button";
import ReactDatePicker from "react-datepicker";
import moment from "moment";
import axios from "axios";
import {getExam} from "@/utils/exams";
import {toast} from "react-toastify";
import {Assignment} from "@/interfaces/results";
import { getExam } from "@/utils/exams";
import { toast } from "react-toastify";
import { Assignment } from "@/interfaces/results";
import Checkbox from "@/components/Low/Checkbox";
import {InstructorGender, Variant} from "@/interfaces/exam";
import { InstructorGender, Variant } from "@/interfaces/exam";
import Select from "@/components/Low/Select";
import useExams from "@/hooks/useExams";
@@ -31,41 +39,78 @@ interface Props {
cancelCreation: () => void;
}
export default function AssignmentCreator({isCreating, assignment, assigner, groups, users, cancelCreation}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>(assignment?.exams.map((e) => e.module) || []);
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}));
export default function AssignmentCreator({
isCreating,
assignment,
assigner,
groups,
users,
cancelCreation,
}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>(
assignment?.exams.map((e) => e.module) || []
);
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() : new Date());
const [startDate, setStartDate] = useState<Date | null>(
assignment ? moment(assignment.startDate).toDate() : new Date()
);
const [endDate, setEndDate] = useState<Date | null>(
assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(),
assignment
? moment(assignment.endDate).toDate()
: moment().hours(23).minutes(59).add(8, "day").toDate()
);
const [variant, setVariant] = useState<Variant>("full");
const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied");
const [instructorGender, setInstructorGender] = useState<InstructorGender>(
assignment?.instructorGender || "varied"
);
// creates a new exam for each assignee or just one exam for all assignees
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
const [useRandomExams, setUseRandomExams] = useState(true);
const [examIDs, setExamIDs] = useState<{id: string; module: Module}[]>([]);
const [examIDs, setExamIDs] = useState<{ id: string; module: Module }[]>([]);
const {exams} = useExams();
const { exams } = useExams();
useEffect(() => {
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module)));
setExamIDs((prev) =>
prev.filter((x) => selectedModules.includes(x.module))
);
}, [selectedModules]);
const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module);
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
setSelectedModules((prev) =>
prev.includes(module) ? modules : [...modules, module]
);
};
const toggleAssignee = (user: User) => {
setAssignees((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id]));
setAssignees((prev) =>
prev.includes(user.id)
? prev.filter((a) => a !== user.id)
: [...prev, user.id]
);
};
const createAssignment = () => {
setIsLoading(true);
(assignment ? axios.patch : axios.post)(`/api/assignments${assignment ? `/${assignment.id}` : ""}`, {
(assignment ? axios.patch : axios.post)(
`/api/assignments${assignment ? `/${assignment.id}` : ""}`,
{
assignees,
name,
startDate,
@@ -75,9 +120,14 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
generateMultiple,
variant,
instructorGender,
})
}
)
.then(() => {
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
toast.success(
`The assignment "${name}" has been ${
assignment ? "updated" : "created"
} successfully!`
);
cancelCreation();
})
.catch((e) => {
@@ -91,11 +141,18 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
if (assignment) {
setIsLoading(true);
if (!confirm(`Are you sure you want to delete the "${assignment.name}" assignment?`)) return;
if (
!confirm(
`Are you sure you want to delete the "${assignment.name}" assignment?`
)
)
return;
axios
.delete(`api/assignments/${assignment.id}`)
.then(() => {
toast.success(`The assignment "${name}" has been deleted successfully!`);
toast.success(
`The assignment "${name}" has been deleted successfully!`
);
cancelCreation();
})
.catch((e) => {
@@ -106,111 +163,199 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
}
};
const startAssignment = () => {
if (assignment) {
setIsLoading(true);
axios
.post(`/api/assignments/${assignment.id}/start`)
.then(() => {
toast.success(
`The assignment "${name}" has been started successfully!`
);
cancelCreation();
})
.catch((e) => {
console.log(e);
toast.error("Something went wrong, please try again later!");
})
.finally(() => setIsLoading(false));
}
}
return (
<Modal isOpen={isCreating} onClose={cancelCreation} title="New Assignment">
<div className="w-full flex flex-col gap-4">
<section className="w-full grid -md:grid-cols-1 md:grid-cols-2 place-items-center lg:grid-cols-6 -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8">
<div
onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
onClick={
!selectedModules.includes("level")
? () => toggleModule("reading")
: undefined
}
className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-2",
selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
selectedModules.includes("reading")
? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsBook className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Reading</span>
{!selectedModules.includes("reading") && !selectedModules.includes("level") && (
{!selectedModules.includes("reading") &&
!selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("reading") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
{selectedModules.includes("level") && (
<BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("reading") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div>
<div
onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
onClick={
!selectedModules.includes("level")
? () => toggleModule("listening")
: undefined
}
className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-2",
selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
selectedModules.includes("listening")
? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsHeadphones className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Listening</span>
{!selectedModules.includes("listening") && !selectedModules.includes("level") && (
{!selectedModules.includes("listening") &&
!selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("listening") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
{selectedModules.includes("level") && (
<BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("listening") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div>
<div
onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
onClick={
!selectedModules.includes("level")
? () => toggleModule("writing")
: undefined
}
className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-2",
selectedModules.includes("writing") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
selectedModules.includes("writing")
? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsPen className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Writing</span>
{!selectedModules.includes("writing") && !selectedModules.includes("level") && (
{!selectedModules.includes("writing") &&
!selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("writing") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
{selectedModules.includes("level") && (
<BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("writing") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div>
<div
onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
onClick={
!selectedModules.includes("level")
? () => toggleModule("speaking")
: undefined
}
className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-3",
selectedModules.includes("speaking") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
selectedModules.includes("speaking")
? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsMegaphone className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Speaking</span>
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && (
{!selectedModules.includes("speaking") &&
!selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("speaking") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
{selectedModules.includes("level") && (
<BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("speaking") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div>
<div
onClick={
(!selectedModules.includes("level") && selectedModules.length === 0) || selectedModules.includes("level")
(!selectedModules.includes("level") &&
selectedModules.length === 0) ||
selectedModules.includes("level")
? () => toggleModule("level")
: undefined
}
className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-3",
selectedModules.includes("level") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
selectedModules.includes("level")
? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsClipboard className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Level</span>
{!selectedModules.includes("level") && selectedModules.length === 0 && (
{!selectedModules.includes("level") &&
selectedModules.length === 0 && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{!selectedModules.includes("level") && selectedModules.length > 0 && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("level") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
{!selectedModules.includes("level") &&
selectedModules.length > 0 && (
<BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("level") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div>
</section>
<Input type="text" name="name" onChange={(e) => setName(e)} defaultValue={name} label="Assignment Name" required />
<Input
type="text"
name="name"
onChange={(e) => setName(e)}
defaultValue={name}
label="Assignment Name"
required
/>
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
<div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">Start Date *</label>
<label className="font-normal text-base text-mti-gray-dim">
Start Date *
</label>
<ReactDatePicker
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out",
"transition duration-300 ease-in-out"
)}
popperClassName="!z-20"
filterTime={(date) => moment(date).isSameOrAfter(new Date())}
@@ -221,12 +366,14 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">End Date *</label>
<label className="font-normal text-base text-mti-gray-dim">
End Date *
</label>
<ReactDatePicker
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out",
"transition duration-300 ease-in-out"
)}
popperClassName="!z-20"
filterTime={(date) => moment(date).isAfter(startDate)}
@@ -240,15 +387,24 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
{selectedModules.includes("speaking") && (
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor&apos;s Gender</label>
<label className="font-normal text-base text-mti-gray-dim">
Speaking Instructor&apos;s Gender
</label>
<Select
value={{value: instructorGender, label: capitalize(instructorGender)}}
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)}
value={{
value: instructorGender,
label: capitalize(instructorGender),
}}
onChange={(value) =>
value
? setInstructorGender(value.value as InstructorGender)
: null
}
disabled={!selectedModules.includes("speaking") || !!assignment}
options={[
{value: "male", label: "Male"},
{value: "female", label: "Female"},
{value: "varied", label: "Varied"},
{ value: "male", label: "Male" },
{ value: "female", label: "Female" },
{ value: "varied", label: "Varied" },
]}
/>
</div>
@@ -263,20 +419,29 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
<div className="grid md:grid-cols-2 w-full gap-4">
{selectedModules.map((module) => (
<div key={module} className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">{capitalize(module)} Exam</label>
<label className="font-normal text-base text-mti-gray-dim">
{capitalize(module)} Exam
</label>
<Select
value={{
value: examIDs.find((e) => e.module === module)?.id || null,
label: examIDs.find((e) => e.module === module)?.id || "",
value:
examIDs.find((e) => e.module === module)?.id || null,
label:
examIDs.find((e) => e.module === module)?.id || "",
}}
onChange={(value) =>
value
? setExamIDs((prev) => [...prev.filter((x) => x.module !== module), {id: value.value!, module}])
: setExamIDs((prev) => prev.filter((x) => x.module !== module))
? setExamIDs((prev) => [
...prev.filter((x) => x.module !== module),
{ id: value.value!, module },
])
: setExamIDs((prev) =>
prev.filter((x) => x.module !== module)
)
}
options={exams
.filter((x) => !x.isDiagnostic && x.module === module)
.map((x) => ({value: x.id, label: x.id}))}
.map((x) => ({ value: x.id, label: x.id }))}
/>
</div>
))}
@@ -286,25 +451,37 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
)}
<section className="w-full flex flex-col gap-3">
<span className="font-semibold">Assignees ({assignees.length} selected)</span>
<span className="font-semibold">
Assignees ({assignees.length} selected)
</span>
<div className="flex gap-4 overflow-x-scroll scrollbar-hide">
{groups.map((g) => (
<button
key={g.id}
onClick={() => {
const groupStudentIds = users.filter((u) => g.participants.includes(u.id)).map((u) => u.id);
const groupStudentIds = users
.filter((u) => g.participants.includes(u.id))
.map((u) => u.id);
if (groupStudentIds.every((u) => assignees.includes(u))) {
setAssignees((prev) => prev.filter((a) => !groupStudentIds.includes(a)));
setAssignees((prev) =>
prev.filter((a) => !groupStudentIds.includes(a))
);
} else {
setAssignees((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]);
setAssignees((prev) => [
...prev.filter((a) => !groupStudentIds.includes(a)),
...groupStudentIds,
]);
}
}}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
users.filter((u) => g.participants.includes(u.id)).every((u) => assignees.includes(u.id)) &&
"!bg-mti-purple-light !text-white",
)}>
users
.filter((u) => g.participants.includes(u.id))
.every((u) => assignees.includes(u.id)) &&
"!bg-mti-purple-light !text-white"
)}
>
{g.name}
</button>
))}
@@ -316,9 +493,12 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
className={clsx(
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
"transition ease-in-out duration-300",
assignees.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum",
assignees.includes(user.id)
? "border-mti-purple"
: "border-mti-gray-platinum"
)}
key={user.id}>
key={user.id}
>
<span className="flex flex-col gap-0 justify-center">
<span className="font-semibold">{user.name}</span>
<span className="text-sm opacity-80">{user.email}</span>
@@ -342,27 +522,54 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
</div>
</section>
<div className="flex flex-col gap-4 w-full items-end">
<Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
<Checkbox
isChecked={variant === "full"}
onChange={() =>
setVariant((prev) => (prev === "full" ? "partial" : "full"))
}
>
Full length exams
</Checkbox>
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
<Checkbox
isChecked={generateMultiple}
onChange={() => setGenerateMultiple((d) => !d)}
>
Generate different exams
</Checkbox>
</div>
<div className="flex gap-4 w-full justify-end">
<Button className="w-full max-w-[200px]" variant="outline" onClick={cancelCreation} disabled={isLoading} isLoading={isLoading}>
<Button
className="w-full max-w-[200px]"
variant="outline"
onClick={cancelCreation}
disabled={isLoading}
isLoading={isLoading}
>
Cancel
</Button>
{assignment && (
<>
<Button
className="w-full max-w-[200px]"
color="green"
variant="outline"
onClick={startAssignment}
disabled={isLoading || moment().isAfter(startDate)}
isLoading={isLoading}
>
Start
</Button>
<Button
className="w-full max-w-[200px]"
color="red"
variant="outline"
onClick={deleteAssignment}
disabled={isLoading}
isLoading={isLoading}>
isLoading={isLoading}
>
Delete
</Button>
</>
)}
<Button
disabled={
@@ -375,7 +582,8 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
}
className="w-full max-w-[200px]"
onClick={createAssignment}
isLoading={isLoading}>
isLoading={isLoading}
>
{assignment ? "Update" : "Create"}
</Button>
</div>

View File

@@ -2,11 +2,11 @@
import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers";
import {CorporateUser, Group, Stat, User} from "@/interfaces/user";
import { CorporateUser, Group, Stat, User } from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils";
import { dateSorter } from "@/utils";
import moment from "moment";
import {useEffect, useState} from "react";
import { useEffect, useState } from "react";
import {
BsArrowLeft,
BsClipboard2Data,
@@ -29,33 +29,51 @@ import {
} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {Module} from "@/interfaces";
import {groupByExam} from "@/utils/stats";
import {
averageLevelCalculator,
calculateAverageLevel,
calculateBandScore,
} from "@/utils/score";
import { MODULE_ARRAY } from "@/utils/moduleUtils";
import { Module } from "@/interfaces";
import { groupByExam } from "@/utils/stats";
import IconCard from "./IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList";
import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router";
import { useRouter } from "next/router";
import useCodes from "@/hooks/useCodes";
import {getUserCorporate} from "@/utils/groups";
import { getUserCorporate } from "@/utils/groups";
import useAssignments from "@/hooks/useAssignments";
import {Assignment} from "@/interfaces/results";
import { Assignment } from "@/interfaces/results";
import AssignmentView from "./AssignmentView";
import AssignmentCreator from "./AssignmentCreator";
import clsx from "clsx";
import AssignmentCard from "./AssignmentCard";
import {createColumnHelper} from "@tanstack/react-table";
import { createColumnHelper } from "@tanstack/react-table";
import Checkbox from "@/components/Low/Checkbox";
import List from "@/components/List";
import {getUserCompanyName} from "@/resources/user";
import { getUserCompanyName } from "@/resources/user";
import {
futureAssignmentFilter,
pastAssignmentFilter,
archivedAssignmentFilter,
activeAssignmentFilter
} from '@/utils/assignments';
interface Props {
user: CorporateUser;
}
type StudentPerformanceItem = User & {corporateName: string; group: string};
const StudentPerformanceList = ({items, stats, users}: {items: StudentPerformanceItem[]; stats: Stat[]; users: User[]}) => {
type StudentPerformanceItem = User & { corporateName: string; group: string };
const StudentPerformanceList = ({
items,
stats,
users,
}: {
items: StudentPerformanceItem[];
stats: Stat[];
users: User[];
}) => {
const [isShowingAmount, setIsShowingAmount] = useState(false);
const columnHelper = createColumnHelper<StudentPerformanceItem>();
@@ -86,35 +104,81 @@ const StudentPerformanceList = ({items, stats, users}: {items: StudentPerformanc
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`,
: `${
Object.keys(
groupByExam(
stats.filter(
(x) =>
x.module === "reading" && x.user === info.row.original.id
)
)
).length
} exams`,
}),
columnHelper.accessor("levels.listening", {
header: "Listening",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`,
: `${
Object.keys(
groupByExam(
stats.filter(
(x) =>
x.module === "listening" &&
x.user === info.row.original.id
)
)
).length
} exams`,
}),
columnHelper.accessor("levels.writing", {
header: "Writing",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`,
: `${
Object.keys(
groupByExam(
stats.filter(
(x) =>
x.module === "writing" && x.user === info.row.original.id
)
)
).length
} exams`,
}),
columnHelper.accessor("levels.speaking", {
header: "Speaking",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`,
: `${
Object.keys(
groupByExam(
stats.filter(
(x) =>
x.module === "speaking" && x.user === info.row.original.id
)
)
).length
} exams`,
}),
columnHelper.accessor("levels.level", {
header: "Level",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`,
: `${
Object.keys(
groupByExam(
stats.filter(
(x) =>
x.module === "level" && x.user === info.row.original.id
)
)
).length
} exams`,
}),
columnHelper.accessor("levels", {
id: "overall_level",
@@ -123,9 +187,15 @@ const StudentPerformanceList = ({items, stats, users}: {items: StudentPerformanc
!isShowingAmount
? averageLevelCalculator(
users,
stats.filter((x) => x.user === info.row.original.id),
stats.filter((x) => x.user === info.row.original.id)
).toFixed(1)
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`,
: `${
Object.keys(
groupByExam(
stats.filter((x) => x.user === info.row.original.id)
)
).length
} exams`,
}),
];
@@ -139,12 +209,12 @@ const StudentPerformanceList = ({items, stats, users}: {items: StudentPerformanc
(a, b) =>
averageLevelCalculator(
users,
stats.filter((x) => x.user === b.id),
stats.filter((x) => x.user === b.id)
) -
averageLevelCalculator(
users,
stats.filter((x) => x.user === a.id),
),
stats.filter((x) => x.user === a.id)
)
)}
columns={columns}
/>
@@ -152,20 +222,25 @@ const StudentPerformanceList = ({items, stats, users}: {items: StudentPerformanc
);
};
export default function CorporateDashboard({user}: Props) {
export default function CorporateDashboard({ user }: Props) {
const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
const [corporateUserToShow, setCorporateUserToShow] =
useState<CorporateUser>();
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
const [userBalance, setUserBalance] = useState(0);
const {stats} = useStats();
const {users, reload, isLoading} = useUsers();
const {codes} = useCodes(user.id);
const {groups} = useGroups({admin: user.id});
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
const { stats } = useStats();
const { users, reload, isLoading } = useUsers();
const { codes } = useCodes(user.id);
const { groups } = useGroups({ admin: user.id });
const {
assignments,
isLoading: isAssignmentsLoading,
reload: reloadAssignments,
} = useAssignments({ corporate: user.id });
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter();
@@ -175,9 +250,14 @@ export default function CorporateDashboard({user}: Props) {
}, [selectedUser, page]);
useEffect(() => {
const relatedGroups = groups.filter((x) => x.name === "Students" || x.name === "Teachers" || x.name === "Corporate");
const relatedGroups = groups.filter(
(x) =>
x.name === "Students" || x.name === "Teachers" || x.name === "Corporate"
);
const usersInGroups = relatedGroups.map((x) => x.participants).flat();
const filteredCodes = codes.filter((x) => !x.userId || !usersInGroups.includes(x.userId));
const filteredCodes = codes.filter(
(x) => !x.userId || !usersInGroups.includes(x.userId)
);
setUserBalance(usersInGroups.length + filteredCodes.length);
}, [codes, groups]);
@@ -187,16 +267,26 @@ export default function CorporateDashboard({user}: Props) {
getUserCorporate(user.id).then(setCorporateUserToShow);
}, [user]);
const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id);
const teacherFilter = (user: User) => user.type === "teacher" && groups.flatMap((g) => g.participants).includes(user.id);
const studentFilter = (user: User) =>
user.type === "student" &&
groups.flatMap((g) => g.participants).includes(user.id);
const teacherFilter = (user: User) =>
user.type === "teacher" &&
groups.flatMap((g) => g.participants).includes(user.id);
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
const getStatsByStudent = (user: User) =>
stats.filter((s) => s.user === user.id);
const UserDisplay = (displayUser: User) => (
<div
onClick={() => setSelectedUser(displayUser)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"
>
<img
src={displayUser.profilePicture}
alt={displayUser.name}
className="rounded-full w-10 h-10"
/>
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
@@ -222,7 +312,8 @@ export default function CorporateDashboard({user}: Props) {
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
@@ -251,7 +342,8 @@ export default function CorporateDashboard({user}: Props) {
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
@@ -263,18 +355,22 @@ export default function CorporateDashboard({user}: Props) {
};
const GroupsList = () => {
const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id);
const filter = (x: Group) =>
x.admin === user.id || x.participants.includes(user.id);
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Groups ({groups.filter(filter).length})</h2>
<h2 className="text-2xl font-semibold">
Groups ({groups.filter(filter).length})
</h2>
</div>
<GroupList user={user} />
@@ -283,12 +379,6 @@ export default function CorporateDashboard({user}: Props) {
};
const AssignmentsPage = () => {
const activeFilter = (a: Assignment) =>
moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length;
const pastFilter = (a: Assignment) => (moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length) && !a.archived;
const archivedFilter = (a: Assignment) => a.archived;
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
return (
<>
<AssignmentView
@@ -302,7 +392,9 @@ export default function CorporateDashboard({user}: Props) {
/>
<AssignmentCreator
assignment={selectedAssignment}
groups={groups.filter((x) => x.admin === user.id || x.participants.includes(user.id))}
groups={groups.filter(
(x) => x.admin === user.id || x.participants.includes(user.id)
)}
users={users.filter(
(x) =>
x.type === "student" &&
@@ -311,7 +403,7 @@ export default function CorporateDashboard({user}: Props) {
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id)),
: groups.flatMap((g) => g.participants).includes(x.id))
)}
assigner={user.id}
isCreating={isCreatingAssignment}
@@ -324,35 +416,52 @@ export default function CorporateDashboard({user}: Props) {
<div className="w-full flex justify-between items-center">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<div
onClick={reloadAssignments}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<span>Reload</span>
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
<BsArrowRepeat
className={clsx(
"text-xl",
isAssignmentsLoading && "animate-spin"
)}
/>
</div>
</div>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeFilter).length})</h2>
<h2 className="text-2xl font-semibold">
Active Assignments ({assignments.filter(activeAssignmentFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(activeFilter).map((a) => (
<AssignmentCard {...a} users={users} onClick={() => setSelectedAssignment(a)} key={a.id} />
{assignments.filter(activeAssignmentFilter).map((a) => (
<AssignmentCard
{...a}
users={users}
onClick={() => setSelectedAssignment(a)}
key={a.id}
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureFilter).length})</h2>
<h2 className="text-2xl font-semibold">
Planned Assignments ({assignments.filter(futureAssignmentFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
<div
onClick={() => setIsCreatingAssignment(true)}
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300"
>
<BsPlus className="text-6xl" />
<span className="text-lg">New Assignment</span>
</div>
{assignments.filter(futureFilter).map((a) => (
{assignments.filter(futureAssignmentFilter).map((a) => (
<AssignmentCard
{...a}
users={users}
@@ -366,9 +475,11 @@ export default function CorporateDashboard({user}: Props) {
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
<h2 className="text-2xl font-semibold">
Past Assignments ({assignments.filter(pastAssignmentFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(pastFilter).map((a) => (
{assignments.filter(pastAssignmentFilter).map((a) => (
<AssignmentCard
{...a}
users={users}
@@ -383,9 +494,11 @@ export default function CorporateDashboard({user}: Props) {
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Archived Assignments ({assignments.filter(archivedFilter).length})</h2>
<h2 className="text-2xl font-semibold">
Archived Assignments ({assignments.filter(archivedAssignmentFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(archivedFilter).map((a) => (
{assignments.filter(archivedAssignmentFilter).map((a) => (
<AssignmentCard
{...a}
users={users}
@@ -405,7 +518,11 @@ export default function CorporateDashboard({user}: Props) {
const StudentPerformancePage = () => {
const students = users
.filter((x) => x.type === "student" && groups.flatMap((g) => g.participants).includes(x.id))
.filter(
(x) =>
x.type === "student" &&
groups.flatMap((g) => g.participants).includes(x.id)
)
.map((u) => ({
...u,
group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A",
@@ -417,15 +534,19 @@ export default function CorporateDashboard({user}: Props) {
<div className="w-full flex justify-between items-center">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<div
onClick={reload}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<span>Reload</span>
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
<BsArrowRepeat
className={clsx("text-xl", isLoading && "animate-spin")}
/>
</div>
</div>
<StudentPerformanceList items={students} stats={stats} users={users} />
@@ -443,10 +564,15 @@ export default function CorporateDashboard({user}: Props) {
.filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({
module: s.module,
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
level: calculateBandScore(
s.score.correct,
s.score.total,
s.module,
s.focus!
),
}));
const levels: {[key in Module]: number} = {
const levels: { [key in Module]: number } = {
reading: 0,
listening: 0,
writing: 0,
@@ -462,7 +588,11 @@ export default function CorporateDashboard({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 className="grid grid-cols-5 -md:grid-cols-2 gap-4 text-center">
@@ -483,26 +613,46 @@ export default function CorporateDashboard({user}: Props) {
<IconCard
Icon={BsClipboard2Data}
label="Exams Performed"
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
value={
stats.filter((s) =>
groups.flatMap((g) => g.participants).includes(s.user)
).length
}
color="purple"
/>
<IconCard
Icon={BsPaperclip}
label="Average Level"
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
value={averageLevelCalculator(
stats.filter((s) =>
groups.flatMap((g) => g.participants).includes(s.user)
)
).toFixed(1)}
color="purple"
/>
<IconCard
onClick={() => setPage("groups")}
Icon={BsPeople}
label="Groups"
value={groups.length}
color="purple"
/>
<IconCard onClick={() => setPage("groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
<IconCard
Icon={BsPersonCheck}
label="User Balance"
value={`${userBalance}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
value={`${userBalance}/${
user.corporateInformation?.companyInformation?.userAmount || 0
}`}
color="purple"
/>
<IconCard
Icon={BsClock}
label="Expiration Date"
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
value={
user.subscriptionExpirationDate
? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy")
: "Unlimited"
}
color="rose"
/>
<IconCard
@@ -515,12 +665,15 @@ export default function CorporateDashboard({user}: Props) {
<button
disabled={isAssignmentsLoading}
onClick={() => setPage("assignments")}
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300"
>
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
<span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">Assignments</span>
<span className="font-semibold text-mti-purple-light">
{isAssignmentsLoading ? "Loading..." : assignments.filter((a) => !a.archived).length}
{isAssignmentsLoading
? "Loading..."
: assignments.filter((a) => !a.archived).length}
</span>
</span>
</button>
@@ -554,7 +707,11 @@ export default function CorporateDashboard({user}: Props) {
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
.sort(
(a, b) =>
calculateAverageLevel(b.levels) -
calculateAverageLevel(a.levels)
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
@@ -567,7 +724,8 @@ export default function CorporateDashboard({user}: Props) {
.filter(studentFilter)
.sort(
(a, b) =>
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
Object.keys(groupByExam(getStatsByStudent(b))).length -
Object.keys(groupByExam(getStatsByStudent(a))).length
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
@@ -591,7 +749,8 @@ export default function CorporateDashboard({user}: Props) {
if (shouldReload) reload();
}}
onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "teacher"
selectedUser.type === "corporate" ||
selectedUser.type === "teacher"
? () => {
appendUserFilters({
id: "view-students",
@@ -601,7 +760,11 @@ export default function CorporateDashboard({user}: Props) {
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.filter(
(g) =>
g.admin === selectedUser.id ||
g.participants.includes(selectedUser.id)
)
.flatMap((g) => g.participants)
.includes(x.id),
});
@@ -611,7 +774,8 @@ export default function CorporateDashboard({user}: Props) {
: undefined
}
onViewTeachers={
selectedUser.type === "corporate" || selectedUser.type === "student"
selectedUser.type === "corporate" ||
selectedUser.type === "student"
? () => {
appendUserFilters({
id: "view-teachers",
@@ -621,7 +785,11 @@ export default function CorporateDashboard({user}: Props) {
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.filter(
(g) =>
g.admin === selectedUser.id ||
g.participants.includes(selectedUser.id)
)
.flatMap((g) => g.participants)
.includes(x.id),
});

View File

@@ -66,21 +66,17 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import MasterStatistical from "./MasterStatistical";
import {
futureAssignmentFilter,
pastAssignmentFilter,
archivedAssignmentFilter,
activeAssignmentFilter
} from '@/utils/assignments';
interface Props {
user: MasterCorporateUser;
}
const activeFilter = (a: Assignment) =>
moment(a.endDate).isAfter(moment()) &&
moment(a.startDate).isBefore(moment()) &&
a.assignees.length > a.results.length;
const pastFilter = (a: Assignment) =>
(moment(a.endDate).isBefore(moment()) ||
a.assignees.length === a.results.length) &&
!a.archived;
const archivedFilter = (a: Assignment) => a.archived;
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
type StudentPerformanceItem = User & {
corporate?: CorporateUser;
@@ -469,7 +465,7 @@ export default function MasterCorporateDashboard({ user }: Props) {
useEffect(() => {
setCorporateAssignments(
assignments.filter(activeFilter).map((a) => ({
assignments.filter(activeAssignmentFilter).map((a) => ({
...a,
corporate: !!users.find((x) => x.id === a.assigner)
? getCorporateUser(
@@ -606,19 +602,6 @@ export default function MasterCorporateDashboard({ user }: Props) {
);
};
// const AssignmentsPage = () => {
// const activeFilter = (a: Assignment) =>
// moment(a.endDate).isAfter(moment()) &&
// moment(a.startDate).isBefore(moment()) &&
// a.assignees.length > a.results.length;
// const pastFilter = (a: Assignment) =>
// (moment(a.endDate).isBefore(moment()) ||
// a.assignees.length === a.results.length) &&
// !a.archived;
// const archivedFilter = (a: Assignment) => a.archived;
// const futureFilter = (a: Assignment) =>
// moment(a.startDate).isAfter(moment());
const StudentPerformancePage = () => {
const students = users
.filter(
@@ -727,11 +710,11 @@ export default function MasterCorporateDashboard({ user }: Props) {
<span>
<b>Total:</b>{" "}
{assignments
.filter(activeFilter)
.filter(activeAssignmentFilter)
.reduce((acc, curr) => acc + curr.results.length, 0)}
/
{assignments
.filter(activeFilter)
.filter(activeAssignmentFilter)
.reduce((acc, curr) => curr.exams.length + acc, 0)}
</span>
{Object.keys(
@@ -761,10 +744,10 @@ export default function MasterCorporateDashboard({ user }: Props) {
</div>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">
Active Assignments ({assignments.filter(activeFilter).length})
Active Assignments ({assignments.filter(activeAssignmentFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(activeFilter).map((a) => (
{assignments.filter(activeAssignmentFilter).map((a) => (
<AssignmentCard
{...a}
users={users}
@@ -776,7 +759,7 @@ export default function MasterCorporateDashboard({ user }: Props) {
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">
Planned Assignments ({assignments.filter(futureFilter).length})
Planned Assignments ({assignments.filter(futureAssignmentFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
<div
@@ -786,7 +769,7 @@ export default function MasterCorporateDashboard({ user }: Props) {
<BsPlus className="text-6xl" />
<span className="text-lg">New Assignment</span>
</div>
{assignments.filter(futureFilter).map((a) => (
{assignments.filter(futureAssignmentFilter).map((a) => (
<AssignmentCard
{...a}
users={users}
@@ -801,10 +784,10 @@ export default function MasterCorporateDashboard({ user }: Props) {
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">
Past Assignments ({assignments.filter(pastFilter).length})
Past Assignments ({assignments.filter(pastAssignmentFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(pastFilter).map((a) => (
{assignments.filter(pastAssignmentFilter).map((a) => (
<AssignmentCard
{...a}
users={users}
@@ -820,10 +803,10 @@ export default function MasterCorporateDashboard({ user }: Props) {
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">
Archived Assignments ({assignments.filter(archivedFilter).length})
Archived Assignments ({assignments.filter(archivedAssignmentFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(archivedFilter).map((a) => (
{assignments.filter(archivedAssignmentFilter).map((a) => (
<AssignmentCard
{...a}
users={users}

View File

@@ -174,14 +174,14 @@ export default function StudentDashboard({user}: Props) {
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
data-tip="Your screen size is too small to perform an assignment">
<Button
disabled={moment(assignment.startDate).isAfter(moment())}
disabled={!assignment.start}
className="h-full w-full !rounded-xl"
variant="outline">
Start
</Button>
</div>
<Button
disabled={moment(assignment.startDate).isAfter(moment())}
disabled={!assignment.start}
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
onClick={() => startAssignment(assignment)}
variant="outline">

View File

@@ -2,11 +2,11 @@
import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers";
import {CorporateUser, Group, Stat, User} from "@/interfaces/user";
import { CorporateUser, Group, Stat, User } from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils";
import { dateSorter } from "@/utils";
import moment from "moment";
import {useEffect, useState} from "react";
import { useEffect, useState } from "react";
import {
BsArrowLeft,
BsArrowRepeat,
@@ -31,41 +31,52 @@ import {
} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {Module} from "@/interfaces";
import {groupByExam} from "@/utils/stats";
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
import { MODULE_ARRAY } from "@/utils/moduleUtils";
import { Module } from "@/interfaces";
import { groupByExam } from "@/utils/stats";
import IconCard from "./IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList";
import useAssignments from "@/hooks/useAssignments";
import {Assignment} from "@/interfaces/results";
import { Assignment } from "@/interfaces/results";
import AssignmentCard from "./AssignmentCard";
import Button from "@/components/Low/Button";
import clsx from "clsx";
import ProgressBar from "@/components/Low/ProgressBar";
import AssignmentCreator from "./AssignmentCreator";
import AssignmentView from "./AssignmentView";
import {getUserCorporate} from "@/utils/groups";
import {checkAccess} from "@/utils/permissions";
import { getUserCorporate } from "@/utils/groups";
import { checkAccess } from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions";
import {
futureAssignmentFilter,
pastAssignmentFilter,
archivedAssignmentFilter,
activeAssignmentFilter
} from '@/utils/assignments';
interface Props {
user: User;
}
export default function TeacherDashboard({user}: Props) {
export default function TeacherDashboard({ user }: Props) {
const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
const [corporateUserToShow, setCorporateUserToShow] =
useState<CorporateUser>();
const {stats} = useStats();
const {users, reload} = useUsers();
const {groups} = useGroups({adminAdmins: user.id});
const {permissions} = usePermissions(user.id);
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id});
const { stats } = useStats();
const { users, reload } = useUsers();
const { groups } = useGroups({ adminAdmins: user.id });
const { permissions } = usePermissions(user.id);
const {
assignments,
isLoading: isAssignmentsLoading,
reload: reloadAssignments,
} = useAssignments({ assigner: user.id });
useEffect(() => {
setShowModal(!!selectedUser && page === "");
@@ -75,15 +86,23 @@ export default function TeacherDashboard({user}: Props) {
getUserCorporate(user.id).then(setCorporateUserToShow);
}, [user]);
const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id);
const studentFilter = (user: User) =>
user.type === "student" &&
groups.flatMap((g) => g.participants).includes(user.id);
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
const getStatsByStudent = (user: User) =>
stats.filter((s) => s.user === user.id);
const UserDisplay = (displayUser: User) => (
<div
onClick={() => setSelectedUser(displayUser)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"
>
<img
src={displayUser.profilePicture}
alt={displayUser.name}
className="rounded-full w-10 h-10"
/>
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
@@ -109,7 +128,8 @@ export default function TeacherDashboard({user}: Props) {
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
@@ -128,11 +148,14 @@ export default function TeacherDashboard({user}: Props) {
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Groups ({groups.filter(filter).length})</h2>
<h2 className="text-2xl font-semibold">
Groups ({groups.filter(filter).length})
</h2>
</div>
<GroupList user={user} />
@@ -150,10 +173,15 @@ export default function TeacherDashboard({user}: Props) {
.filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({
module: s.module,
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
level: calculateBandScore(
s.score.correct,
s.score.total,
s.module,
s.focus!
),
}));
const levels: {[key in Module]: number} = {
const levels: { [key in Module]: number } = {
reading: 0,
listening: 0,
writing: 0,
@@ -166,12 +194,6 @@ export default function TeacherDashboard({user}: Props) {
};
const AssignmentsPage = () => {
const activeFilter = (a: Assignment) =>
moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length;
const pastFilter = (a: Assignment) => (moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length) && !a.archived;
const archivedFilter = (a: Assignment) => a.archived;
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
return (
<>
<AssignmentView
@@ -185,7 +207,9 @@ export default function TeacherDashboard({user}: Props) {
/>
<AssignmentCreator
assignment={selectedAssignment}
groups={groups.filter((x) => x.admin === user.id || x.participants.includes(user.id))}
groups={groups.filter(
(x) => x.admin === user.id || x.participants.includes(user.id)
)}
users={users.filter(
(x) =>
x.type === "student" &&
@@ -194,7 +218,7 @@ export default function TeacherDashboard({user}: Props) {
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id)
: groups.flatMap((g) => g.participants).includes(x.id)),
: groups.flatMap((g) => g.participants).includes(x.id))
)}
assigner={user.id}
isCreating={isCreatingAssignment}
@@ -207,35 +231,52 @@ export default function TeacherDashboard({user}: Props) {
<div className="w-full flex justify-between items-center">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<div
onClick={reloadAssignments}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<span>Reload</span>
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
<BsArrowRepeat
className={clsx(
"text-xl",
isAssignmentsLoading && "animate-spin"
)}
/>
</div>
</div>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeFilter).length})</h2>
<h2 className="text-2xl font-semibold">
Active Assignments ({assignments.filter(activeAssignmentFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(activeFilter).map((a) => (
<AssignmentCard {...a} users={users} onClick={() => setSelectedAssignment(a)} key={a.id} />
{assignments.filter(activeAssignmentFilter).map((a) => (
<AssignmentCard
{...a}
users={users}
onClick={() => setSelectedAssignment(a)}
key={a.id}
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureFilter).length})</h2>
<h2 className="text-2xl font-semibold">
Planned Assignments ({assignments.filter(futureAssignmentFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
<div
onClick={() => setIsCreatingAssignment(true)}
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300"
>
<BsPlus className="text-6xl" />
<span className="text-lg">New Assignment</span>
</div>
{assignments.filter(futureFilter).map((a) => (
{assignments.filter(futureAssignmentFilter).map((a) => (
<AssignmentCard
{...a}
users={users}
@@ -249,9 +290,11 @@ export default function TeacherDashboard({user}: Props) {
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
<h2 className="text-2xl font-semibold">
Past Assignments ({assignments.filter(pastAssignmentFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(pastFilter).map((a) => (
{assignments.filter(pastAssignmentFilter).map((a) => (
<AssignmentCard
{...a}
users={users}
@@ -266,9 +309,11 @@ export default function TeacherDashboard({user}: Props) {
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Archived Assignments ({assignments.filter(archivedFilter).length})</h2>
<h2 className="text-2xl font-semibold">
Archived Assignments ({assignments.filter(archivedAssignmentFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(archivedFilter).map((a) => (
{assignments.filter(archivedAssignmentFilter).map((a) => (
<AssignmentCard
{...a}
users={users}
@@ -290,14 +335,19 @@ 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
className={clsx(
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center",
!!corporateUserToShow && "mt-12 xl:mt-6",
)}>
!!corporateUserToShow && "mt-12 xl:mt-6"
)}
>
<IconCard
onClick={() => setPage("students")}
Icon={BsPersonFill}
@@ -308,16 +358,29 @@ export default function TeacherDashboard({user}: Props) {
<IconCard
Icon={BsClipboard2Data}
label="Exams Performed"
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
value={
stats.filter((s) =>
groups.flatMap((g) => g.participants).includes(s.user)
).length
}
color="purple"
/>
<IconCard
Icon={BsPaperclip}
label="Average Level"
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
value={averageLevelCalculator(
stats.filter((s) =>
groups.flatMap((g) => g.participants).includes(s.user)
)
).toFixed(1)}
color="purple"
/>
{checkAccess(user, ["teacher", "developer"], permissions, "viewGroup") && (
{checkAccess(
user,
["teacher", "developer"],
permissions,
"viewGroup"
) && (
<IconCard
Icon={BsPeople}
label="Groups"
@@ -328,11 +391,14 @@ export default function TeacherDashboard({user}: Props) {
)}
<div
onClick={() => setPage("assignments")}
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300"
>
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
<span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">Assignments</span>
<span className="font-semibold text-mti-purple-light">{assignments.filter((a) => !a.archived).length}</span>
<span className="font-semibold text-mti-purple-light">
{assignments.filter((a) => !a.archived).length}
</span>
</span>
</div>
</section>
@@ -354,7 +420,11 @@ export default function TeacherDashboard({user}: Props) {
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
.sort(
(a, b) =>
calculateAverageLevel(b.levels) -
calculateAverageLevel(a.levels)
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
@@ -367,7 +437,8 @@ export default function TeacherDashboard({user}: Props) {
.filter(studentFilter)
.sort(
(a, b) =>
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
Object.keys(groupByExam(getStatsByStudent(b))).length -
Object.keys(groupByExam(getStatsByStudent(a))).length
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
@@ -391,9 +462,16 @@ export default function TeacherDashboard({user}: Props) {
if (shouldReload) reload();
}}
onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
selectedUser.type === "corporate" ||
selectedUser.type === "teacher"
? () => setPage("students")
: undefined
}
onViewTeachers={
selectedUser.type === "corporate"
? () => setPage("teachers")
: undefined
}
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
user={selectedUser}
/>
</div>

View File

@@ -28,6 +28,9 @@ export interface Assignment {
endDate: Date;
archived?: boolean;
released?: boolean;
// unless start is active, the assignment is not visible to the assignees
// start date now works as a limit time to start the exam
start?: boolean;
}
export type AssignmentWithCorporateId = Assignment & { corporateId: string };

View File

@@ -0,0 +1,46 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
import moment from "moment";
import { getFirestore, doc, getDoc, setDoc } from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function post(req: NextApiRequest, res: NextApiResponse) {
// verify if it's a logged user that is trying to archive
if (req.session.user) {
const { id } = req.query as { id: string };
const docSnap = await getDoc(doc(db, "assignments", id));
if (!docSnap.exists()) {
res.status(404).json({ ok: false });
return;
}
const data = docSnap.data();
if (moment().isAfter(moment(data.startDate))) {
res
.status(400)
.json({ ok: false, message: "Assignmentcan no longer " });
return;
}
await setDoc(
docSnap.ref,
{ start: true },
{ merge: true }
);
res.status(200).json({ ok: true });
return;
}
res.status(401).json({ ok: false });
}
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") return post(req, res);
res.status(404).json({ ok: false });
}

18
src/utils/assignments.ts Normal file
View File

@@ -0,0 +1,18 @@
import moment from "moment";
import { Assignment } from "@/interfaces/results";
export const futureAssignmentFilter = (a: Assignment) =>
moment(a.startDate).isAfter(moment()) && !a.archived && !a.start;
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;