Merged in features-21-08-24 (pull request #79)

ENCOA: 86 + 101 + 102

Approved-by: Tiago Ribeiro
This commit is contained in:
João Ramos
2024-08-25 17:35:59 +00:00
committed by Tiago Ribeiro
19 changed files with 3450 additions and 2169 deletions

View File

@@ -178,6 +178,15 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
} }
}; };
const shouldRenderPDFIcon = () => {
if(assignment) {
return assignment.released;
}
return true;
}
const content = ( const content = (
<> <>
<div className="w-full flex justify-between -md:items-center 2xl:items-center"> <div className="w-full flex justify-between -md:items-center 2xl:items-center">
@@ -202,7 +211,7 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
Level{" "} Level{" "}
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)} {(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
</span> </span>
{renderPdfIcon(session, textColor, textColor)} {shouldRenderPDFIcon() && renderPdfIcon(session, textColor, textColor)}
</div> </div>
{examNumber === undefined ? ( {examNumber === undefined ? (
<> <>

View File

@@ -10,6 +10,7 @@ import {usePDFDownload} from "@/hooks/usePDFDownload";
import {useAssignmentArchive} from "@/hooks/useAssignmentArchive"; import {useAssignmentArchive} from "@/hooks/useAssignmentArchive";
import {uniqBy} from "lodash"; import {uniqBy} from "lodash";
import {useAssignmentUnarchive} from "@/hooks/useAssignmentUnarchive"; import {useAssignmentUnarchive} from "@/hooks/useAssignmentUnarchive";
import {useAssignmentRelease} from "@/hooks/useAssignmentRelease";
import {getUserName} from "@/utils/users"; import {getUserName} from "@/utils/users";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
@@ -40,11 +41,14 @@ export default function AssignmentCard({
allowUnarchive, allowUnarchive,
allowExcelDownload, allowExcelDownload,
users, users,
released,
}: Assignment & Props) { }: Assignment & Props) {
const renderPdfIcon = usePDFDownload("assignments"); const renderPdfIcon = usePDFDownload("assignments");
const renderExcelIcon = usePDFDownload("assignments", "excel"); const renderExcelIcon = usePDFDownload("assignments", "excel");
const renderArchiveIcon = useAssignmentArchive(id, reload); const renderArchiveIcon = useAssignmentArchive(id, reload);
const renderUnarchiveIcon = useAssignmentUnarchive(id, reload); const renderUnarchiveIcon = useAssignmentUnarchive(id, reload);
const renderReleaseIcon = useAssignmentRelease(id, reload);
const calculateAverageModuleScore = (module: Module) => { const calculateAverageModuleScore = (module: Module) => {
const resultModuleBandScores = results.map((r) => { const resultModuleBandScores = results.map((r) => {
@@ -66,10 +70,11 @@ export default function AssignmentCard({
<div className="flex flex-row justify-between"> <div className="flex flex-row justify-between">
<h3 className="text-xl font-semibold">{name}</h3> <h3 className="text-xl font-semibold">{name}</h3>
<div className="flex gap-2"> <div className="flex gap-2">
{allowDownload && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")} {allowDownload && released && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
{allowExcelDownload && renderExcelIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")} {allowExcelDownload && released && renderExcelIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
{allowArchive && !archived && renderArchiveIcon("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")} {allowUnarchive && archived && renderUnarchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
{!released && renderReleaseIcon("text-mti-gray-dim", "text-mti-gray-dim")}
</div> </div>
</div> </div>
<ProgressBar <ProgressBar

View File

@@ -3,7 +3,15 @@ import Modal from "@/components/Modal";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import clsx from "clsx"; import clsx from "clsx";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import {BsBook, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs"; import {
BsBook,
BsCheckCircle,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
BsXCircle,
} from "react-icons/bs";
import { generate } from "random-words"; import { generate } from "random-words";
import { capitalize } from "lodash"; import { capitalize } from "lodash";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
@@ -31,17 +39,44 @@ interface Props {
cancelCreation: () => void; cancelCreation: () => void;
} }
export default function AssignmentCreator({isCreating, assignment, assigner, groups, users, cancelCreation}: Props) { export default function AssignmentCreator({
const [selectedModules, setSelectedModules] = useState<Module[]>(assignment?.exams.map((e) => e.module) || []); isCreating,
const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []); assignment,
const [name, setName] = useState(assignment?.name || generate({minLength: 6, maxLength: 8, min: 2, max: 3, join: " ", formatter: capitalize})); 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 [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>( 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 [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 // creates a new exam for each assignee or just one exam for all assignees
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false); const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
const [useRandomExams, setUseRandomExams] = useState(true); const [useRandomExams, setUseRandomExams] = useState(true);
@@ -50,22 +85,32 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
const { exams } = useExams(); const { exams } = useExams();
useEffect(() => { useEffect(() => {
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module))); setExamIDs((prev) =>
prev.filter((x) => selectedModules.includes(x.module))
);
}, [selectedModules]); }, [selectedModules]);
const toggleModule = (module: Module) => { const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== 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) => { 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 = () => { const createAssignment = () => {
setIsLoading(true); setIsLoading(true);
(assignment ? axios.patch : axios.post)(`/api/assignments${assignment ? `/${assignment.id}` : ""}`, { (assignment ? axios.patch : axios.post)(
`/api/assignments${assignment ? `/${assignment.id}` : ""}`,
{
assignees, assignees,
name, name,
startDate, startDate,
@@ -75,9 +120,14 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
generateMultiple, generateMultiple,
variant, variant,
instructorGender, instructorGender,
}) }
)
.then(() => { .then(() => {
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`); toast.success(
`The assignment "${name}" has been ${
assignment ? "updated" : "created"
} successfully!`
);
cancelCreation(); cancelCreation();
}) })
.catch((e) => { .catch((e) => {
@@ -91,11 +141,18 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
if (assignment) { if (assignment) {
setIsLoading(true); 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 axios
.delete(`api/assignments/${assignment.id}`) .delete(`api/assignments/${assignment.id}`)
.then(() => { .then(() => {
toast.success(`The assignment "${name}" has been deleted successfully!`); toast.success(
`The assignment "${name}" has been deleted successfully!`
);
cancelCreation(); cancelCreation();
}) })
.catch((e) => { .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 ( return (
<Modal isOpen={isCreating} onClose={cancelCreation} title="New Assignment"> <Modal isOpen={isCreating} onClose={cancelCreation} title="New Assignment">
<div className="w-full flex flex-col gap-4"> <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"> <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 <div
onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined} onClick={
!selectedModules.includes("level")
? () => toggleModule("reading")
: undefined
}
className={clsx( 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", "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", "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"> <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" /> <BsBook className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Reading</span> <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" /> <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("level") && (
{selectedModules.includes("reading") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} <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>
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined} onClick={
!selectedModules.includes("level")
? () => toggleModule("listening")
: undefined
}
className={clsx( 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", "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", "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"> <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" /> <BsHeadphones className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Listening</span> <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" /> <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("level") && (
{selectedModules.includes("listening") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} <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>
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined} onClick={
!selectedModules.includes("level")
? () => toggleModule("writing")
: undefined
}
className={clsx( 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", "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", "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"> <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" /> <BsPen className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Writing</span> <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" /> <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("level") && (
{selectedModules.includes("writing") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} <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>
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : undefined} onClick={
!selectedModules.includes("level")
? () => toggleModule("speaking")
: undefined
}
className={clsx( 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", "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", "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"> <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" /> <BsMegaphone className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Speaking</span> <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" /> <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("level") && (
{selectedModules.includes("speaking") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} <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>
<div <div
onClick={ onClick={
(!selectedModules.includes("level") && selectedModules.length === 0) || selectedModules.includes("level") (!selectedModules.includes("level") &&
selectedModules.length === 0) ||
selectedModules.includes("level")
? () => toggleModule("level") ? () => toggleModule("level")
: undefined : undefined
} }
className={clsx( 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", "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", "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"> <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" /> <BsClipboard className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Level</span> <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" /> <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") &&
{selectedModules.includes("level") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} 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> </div>
</section> </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="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">Start Date *</label> <label className="font-normal text-base text-mti-gray-dim">
Start Date *
</label>
<ReactDatePicker <ReactDatePicker
className={clsx( className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip z-10", "hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out"
)} )}
popperClassName="!z-20" popperClassName="!z-20"
filterTime={(date) => moment(date).isSameOrAfter(new Date())} filterTime={(date) => moment(date).isSameOrAfter(new Date())}
@@ -221,12 +366,14 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
/> />
</div> </div>
<div className="flex flex-col gap-2"> <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 <ReactDatePicker
className={clsx( className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip z-10", "hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out"
)} )}
popperClassName="!z-20" popperClassName="!z-20"
filterTime={(date) => moment(date).isAfter(startDate)} filterTime={(date) => moment(date).isAfter(startDate)}
@@ -240,10 +387,19 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
{selectedModules.includes("speaking") && ( {selectedModules.includes("speaking") && (
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor&apos;s Gender</label> <label className="font-normal text-base text-mti-gray-dim">
Speaking Instructor&apos;s Gender
</label>
<Select <Select
value={{value: instructorGender, label: capitalize(instructorGender)}} value={{
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)} value: instructorGender,
label: capitalize(instructorGender),
}}
onChange={(value) =>
value
? setInstructorGender(value.value as InstructorGender)
: null
}
disabled={!selectedModules.includes("speaking") || !!assignment} disabled={!selectedModules.includes("speaking") || !!assignment}
options={[ options={[
{ value: "male", label: "Male" }, { value: "male", label: "Male" },
@@ -263,16 +419,25 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
<div className="grid md:grid-cols-2 w-full gap-4"> <div className="grid md:grid-cols-2 w-full gap-4">
{selectedModules.map((module) => ( {selectedModules.map((module) => (
<div key={module} className="flex flex-col gap-3 w-full"> <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 <Select
value={{ value={{
value: examIDs.find((e) => e.module === module)?.id || null, value:
label: examIDs.find((e) => e.module === module)?.id || "", examIDs.find((e) => e.module === module)?.id || null,
label:
examIDs.find((e) => e.module === module)?.id || "",
}} }}
onChange={(value) => onChange={(value) =>
value value
? setExamIDs((prev) => [...prev.filter((x) => x.module !== module), {id: value.value!, module}]) ? setExamIDs((prev) => [
: setExamIDs((prev) => prev.filter((x) => x.module !== module)) ...prev.filter((x) => x.module !== module),
{ id: value.value!, module },
])
: setExamIDs((prev) =>
prev.filter((x) => x.module !== module)
)
} }
options={exams options={exams
.filter((x) => !x.isDiagnostic && x.module === module) .filter((x) => !x.isDiagnostic && x.module === module)
@@ -286,25 +451,37 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
)} )}
<section className="w-full flex flex-col gap-3"> <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"> <div className="flex gap-4 overflow-x-scroll scrollbar-hide">
{groups.map((g) => ( {groups.map((g) => (
<button <button
key={g.id} key={g.id}
onClick={() => { 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))) { if (groupStudentIds.every((u) => assignees.includes(u))) {
setAssignees((prev) => prev.filter((a) => !groupStudentIds.includes(a))); setAssignees((prev) =>
prev.filter((a) => !groupStudentIds.includes(a))
);
} else { } else {
setAssignees((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]); setAssignees((prev) => [
...prev.filter((a) => !groupStudentIds.includes(a)),
...groupStudentIds,
]);
} }
}} }}
className={clsx( className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", "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", "transition duration-300 ease-in-out",
users.filter((u) => g.participants.includes(u.id)).every((u) => assignees.includes(u.id)) && users
"!bg-mti-purple-light !text-white", .filter((u) => g.participants.includes(u.id))
)}> .every((u) => assignees.includes(u.id)) &&
"!bg-mti-purple-light !text-white"
)}
>
{g.name} {g.name}
</button> </button>
))} ))}
@@ -316,9 +493,12 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
className={clsx( className={clsx(
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72", "p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
"transition ease-in-out duration-300", "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="flex flex-col gap-0 justify-center">
<span className="font-semibold">{user.name}</span> <span className="font-semibold">{user.name}</span>
<span className="text-sm opacity-80">{user.email}</span> <span className="text-sm opacity-80">{user.email}</span>
@@ -342,27 +522,54 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
</div> </div>
</section> </section>
<div className="flex flex-col gap-4 w-full items-end"> <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 Full length exams
</Checkbox> </Checkbox>
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}> <Checkbox
isChecked={generateMultiple}
onChange={() => setGenerateMultiple((d) => !d)}
>
Generate different exams Generate different exams
</Checkbox> </Checkbox>
</div> </div>
<div className="flex gap-4 w-full justify-end"> <div className="flex gap-4 w-full justify-end">
<Button className="w-full max-w-[200px]" variant="outline" onClick={cancelCreation} disabled={isLoading} isLoading={isLoading}> <Button
className="w-full max-w-[200px]"
variant="outline"
onClick={cancelCreation}
disabled={isLoading}
isLoading={isLoading}
>
Cancel Cancel
</Button> </Button>
{assignment && ( {assignment && (
<>
<Button
className="w-full max-w-[200px]"
color="green"
variant="outline"
onClick={startAssignment}
disabled={isLoading || moment().isAfter(startDate)}
isLoading={isLoading}
>
Start
</Button>
<Button <Button
className="w-full max-w-[200px]" className="w-full max-w-[200px]"
color="red" color="red"
variant="outline" variant="outline"
onClick={deleteAssignment} onClick={deleteAssignment}
disabled={isLoading} disabled={isLoading}
isLoading={isLoading}> isLoading={isLoading}
>
Delete Delete
</Button> </Button>
</>
)} )}
<Button <Button
disabled={ disabled={
@@ -375,7 +582,8 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
} }
className="w-full max-w-[200px]" className="w-full max-w-[200px]"
onClick={createAssignment} onClick={createAssignment}
isLoading={isLoading}> isLoading={isLoading}
>
{assignment ? "Update" : "Create"} {assignment ? "Update" : "Create"}
</Button> </Button>
</div> </div>

View File

@@ -29,7 +29,11 @@ import {
} from "react-icons/bs"; } from "react-icons/bs";
import UserCard from "@/components/UserCard"; import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score"; import {
averageLevelCalculator,
calculateAverageLevel,
calculateBandScore,
} from "@/utils/score";
import { MODULE_ARRAY } from "@/utils/moduleUtils"; import { MODULE_ARRAY } from "@/utils/moduleUtils";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { groupByExam } from "@/utils/stats"; import { groupByExam } from "@/utils/stats";
@@ -49,6 +53,12 @@ import {createColumnHelper} from "@tanstack/react-table";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import List from "@/components/List"; import List from "@/components/List";
import { getUserCompanyName } from "@/resources/user"; import { getUserCompanyName } from "@/resources/user";
import {
futureAssignmentFilter,
pastAssignmentFilter,
archivedAssignmentFilter,
activeAssignmentFilter,
} from "@/utils/assignments";
import useUserBalance from "@/hooks/useUserBalance"; import useUserBalance from "@/hooks/useUserBalance";
interface Props { interface Props {
@@ -56,7 +66,15 @@ interface Props {
} }
type StudentPerformanceItem = User & { corporateName: string; group: string }; type StudentPerformanceItem = User & { corporateName: string; group: string };
const StudentPerformanceList = ({items, stats, users}: {items: StudentPerformanceItem[]; stats: Stat[]; users: User[]}) => { const StudentPerformanceList = ({
items,
stats,
users,
}: {
items: StudentPerformanceItem[];
stats: Stat[];
users: User[];
}) => {
const [isShowingAmount, setIsShowingAmount] = useState(false); const [isShowingAmount, setIsShowingAmount] = useState(false);
const columnHelper = createColumnHelper<StudentPerformanceItem>(); const columnHelper = createColumnHelper<StudentPerformanceItem>();
@@ -87,35 +105,81 @@ const StudentPerformanceList = ({items, stats, users}: {items: StudentPerformanc
cell: (info) => cell: (info) =>
!isShowingAmount !isShowingAmount
? info.getValue() || 0 ? 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", { columnHelper.accessor("levels.listening", {
header: "Listening", header: "Listening",
cell: (info) => cell: (info) =>
!isShowingAmount !isShowingAmount
? info.getValue() || 0 ? 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", { columnHelper.accessor("levels.writing", {
header: "Writing", header: "Writing",
cell: (info) => cell: (info) =>
!isShowingAmount !isShowingAmount
? info.getValue() || 0 ? 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", { columnHelper.accessor("levels.speaking", {
header: "Speaking", header: "Speaking",
cell: (info) => cell: (info) =>
!isShowingAmount !isShowingAmount
? info.getValue() || 0 ? 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", { columnHelper.accessor("levels.level", {
header: "Level", header: "Level",
cell: (info) => cell: (info) =>
!isShowingAmount !isShowingAmount
? info.getValue() || 0 ? 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", { columnHelper.accessor("levels", {
id: "overall_level", id: "overall_level",
@@ -124,9 +188,15 @@ const StudentPerformanceList = ({items, stats, users}: {items: StudentPerformanc
!isShowingAmount !isShowingAmount
? averageLevelCalculator( ? averageLevelCalculator(
users, users,
stats.filter((x) => x.user === info.row.original.id), stats.filter((x) => x.user === info.row.original.id)
).toFixed(1) ).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`,
}), }),
]; ];
@@ -140,12 +210,12 @@ const StudentPerformanceList = ({items, stats, users}: {items: StudentPerformanc
(a, b) => (a, b) =>
averageLevelCalculator( averageLevelCalculator(
users, users,
stats.filter((x) => x.user === b.id), stats.filter((x) => x.user === b.id)
) - ) -
averageLevelCalculator( averageLevelCalculator(
users, users,
stats.filter((x) => x.user === a.id), stats.filter((x) => x.user === a.id)
), )
)} )}
columns={columns} columns={columns}
/> />
@@ -157,7 +227,8 @@ export default function CorporateDashboard({user}: Props) {
const [page, setPage] = useState(""); const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>(); const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>(); const [corporateUserToShow, setCorporateUserToShow] =
useState<CorporateUser>();
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>(); const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false); const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
@@ -165,7 +236,11 @@ export default function CorporateDashboard({user}: Props) {
const { users, reload, isLoading } = useUsers(); const { users, reload, isLoading } = useUsers();
const { codes } = useCodes(user.id); const { codes } = useCodes(user.id);
const { groups } = useGroups({ admin: user.id }); const { groups } = useGroups({ admin: user.id });
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id}); const {
assignments,
isLoading: isAssignmentsLoading,
reload: reloadAssignments,
} = useAssignments({ corporate: user.id });
const { balance } = useUserBalance(); const { balance } = useUserBalance();
const appendUserFilters = useFilterStore((state) => state.appendUserFilter); const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
@@ -180,16 +255,26 @@ export default function CorporateDashboard({user}: Props) {
getUserCorporate(user.id).then(setCorporateUserToShow); getUserCorporate(user.id).then(setCorporateUserToShow);
}, [user]); }, [user]);
const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id); const studentFilter = (user: User) =>
const teacherFilter = (user: User) => user.type === "teacher" && groups.flatMap((g) => g.participants).includes(user.id); 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) => ( const UserDisplay = (displayUser: User) => (
<div <div
onClick={() => setSelectedUser(displayUser)} 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"> 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" /> >
<img
src={displayUser.profilePicture}
alt={displayUser.name}
className="rounded-full w-10 h-10"
/>
<div className="flex flex-col gap-1 items-start"> <div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span> <span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span> <span className="text-sm opacity-75">{displayUser.email}</span>
@@ -215,7 +300,8 @@ export default function CorporateDashboard({user}: Props) {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
</div> </div>
@@ -244,7 +330,8 @@ export default function CorporateDashboard({user}: Props) {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
</div> </div>
@@ -256,18 +343,22 @@ export default function CorporateDashboard({user}: Props) {
}; };
const GroupsList = () => { const GroupsList = () => {
const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id); const filter = (x: Group) =>
x.admin === user.id || x.participants.includes(user.id);
return ( return (
<> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
</div> </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> </div>
<GroupList user={user} /> <GroupList user={user} />
@@ -276,12 +367,6 @@ export default function CorporateDashboard({user}: Props) {
}; };
const AssignmentsPage = () => { 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 ( return (
<> <>
<AssignmentView <AssignmentView
@@ -295,7 +380,9 @@ export default function CorporateDashboard({user}: Props) {
/> />
<AssignmentCreator <AssignmentCreator
assignment={selectedAssignment} 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( users={users.filter(
(x) => (x) =>
x.type === "student" && x.type === "student" &&
@@ -304,7 +391,7 @@ export default function CorporateDashboard({user}: Props) {
.filter((g) => g.admin === selectedUser.id) .filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants) .flatMap((g) => g.participants)
.includes(x.id) || false .includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id)), : groups.flatMap((g) => g.participants).includes(x.id))
)} )}
assigner={user.id} assigner={user.id}
isCreating={isCreatingAssignment} isCreating={isCreatingAssignment}
@@ -317,35 +404,54 @@ export default function CorporateDashboard({user}: Props) {
<div className="w-full flex justify-between items-center"> <div className="w-full flex justify-between items-center">
<div <div
onClick={() => setPage("")} 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
</div> </div>
<div <div
onClick={reloadAssignments} 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> <span>Reload</span>
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} /> <BsArrowRepeat
className={clsx(
"text-xl",
isAssignmentsLoading && "animate-spin"
)}
/>
</div> </div>
</div> </div>
<section className="flex flex-col gap-4"> <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"> <div className="flex flex-wrap gap-2">
{assignments.filter(activeFilter).map((a) => ( {assignments.filter(activeAssignmentFilter).map((a) => (
<AssignmentCard {...a} users={users} onClick={() => setSelectedAssignment(a)} key={a.id} /> <AssignmentCard
{...a}
users={users}
onClick={() => setSelectedAssignment(a)}
key={a.id}
/>
))} ))}
</div> </div>
</section> </section>
<section className="flex flex-col gap-4"> <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 className="flex flex-wrap gap-2">
<div <div
onClick={() => setIsCreatingAssignment(true)} 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" /> <BsPlus className="text-6xl" />
<span className="text-lg">New Assignment</span> <span className="text-lg">New Assignment</span>
</div> </div>
{assignments.filter(futureFilter).map((a) => ( {assignments.filter(futureAssignmentFilter).map((a) => (
<AssignmentCard <AssignmentCard
{...a} {...a}
users={users} users={users}
@@ -359,9 +465,11 @@ export default function CorporateDashboard({user}: Props) {
</div> </div>
</section> </section>
<section className="flex flex-col gap-4"> <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"> <div className="flex flex-wrap gap-2">
{assignments.filter(pastFilter).map((a) => ( {assignments.filter(pastAssignmentFilter).map((a) => (
<AssignmentCard <AssignmentCard
{...a} {...a}
users={users} users={users}
@@ -376,9 +484,12 @@ export default function CorporateDashboard({user}: Props) {
</div> </div>
</section> </section>
<section className="flex flex-col gap-4"> <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"> <div className="flex flex-wrap gap-2">
{assignments.filter(archivedFilter).map((a) => ( {assignments.filter(archivedAssignmentFilter).map((a) => (
<AssignmentCard <AssignmentCard
{...a} {...a}
users={users} users={users}
@@ -398,7 +509,11 @@ export default function CorporateDashboard({user}: Props) {
const StudentPerformancePage = () => { const StudentPerformancePage = () => {
const students = users 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) => ({ .map((u) => ({
...u, ...u,
group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A", group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A",
@@ -410,15 +525,19 @@ export default function CorporateDashboard({user}: Props) {
<div className="w-full flex justify-between items-center"> <div className="w-full flex justify-between items-center">
<div <div
onClick={() => setPage("")} 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
</div> </div>
<div <div
onClick={reload} 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> <span>Reload</span>
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} /> <BsArrowRepeat
className={clsx("text-xl", isLoading && "animate-spin")}
/>
</div> </div>
</div> </div>
<StudentPerformanceList items={students} stats={stats} users={users} /> <StudentPerformanceList items={students} stats={stats} users={users} />
@@ -436,7 +555,12 @@ export default function CorporateDashboard({user}: Props) {
.filter((f) => !!f.focus); .filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({ const bandScores = formattedStats.map((s) => ({
module: s.module, 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 } = {
@@ -455,7 +579,11 @@ export default function CorporateDashboard({user}: Props) {
<> <>
{corporateUserToShow && ( {corporateUserToShow && (
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1"> <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> </div>
)} )}
<section className="grid grid-cols-5 -md:grid-cols-2 gap-4 text-center"> <section className="grid grid-cols-5 -md:grid-cols-2 gap-4 text-center">
@@ -476,26 +604,46 @@ export default function CorporateDashboard({user}: Props) {
<IconCard <IconCard
Icon={BsClipboard2Data} Icon={BsClipboard2Data}
label="Exams Performed" 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" color="purple"
/> />
<IconCard <IconCard
Icon={BsPaperclip} Icon={BsPaperclip}
label="Average Level" label="Average Level"
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)} value={averageLevelCalculator(
stats.filter((s) =>
groups.flatMap((g) => g.participants).includes(s.user)
)
).toFixed(1)}
color="purple"
/>
<IconCard
onClick={() => setPage("groups")}
Icon={BsPeople}
label="Groups"
value={groups.length}
color="purple" color="purple"
/> />
<IconCard onClick={() => setPage("groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
<IconCard <IconCard
Icon={BsPersonCheck} Icon={BsPersonCheck}
label="User Balance" label="User Balance"
value={`${balance}/${user.corporateInformation?.companyInformation?.userAmount || 0}`} value={`${balance}/${
user.corporateInformation?.companyInformation?.userAmount || 0
}`}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsClock} Icon={BsClock}
label="Expiration Date" 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" color="rose"
/> />
<IconCard <IconCard
@@ -508,12 +656,15 @@ export default function CorporateDashboard({user}: Props) {
<button <button
disabled={isAssignmentsLoading} disabled={isAssignmentsLoading}
onClick={() => setPage("assignments")} 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" /> <BsEnvelopePaper className="text-6xl text-mti-purple-light" />
<span className="flex flex-col gap-1 items-center text-xl"> <span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">Assignments</span> <span className="text-lg">Assignments</span>
<span className="font-semibold text-mti-purple-light"> <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>
</span> </span>
</button> </button>
@@ -547,7 +698,11 @@ export default function CorporateDashboard({user}: Props) {
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {users
.filter(studentFilter) .filter(studentFilter)
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels)) .sort(
(a, b) =>
calculateAverageLevel(b.levels) -
calculateAverageLevel(a.levels)
)
.map((x) => ( .map((x) => (
<UserDisplay key={x.id} {...x} /> <UserDisplay key={x.id} {...x} />
))} ))}
@@ -560,7 +715,8 @@ export default function CorporateDashboard({user}: Props) {
.filter(studentFilter) .filter(studentFilter)
.sort( .sort(
(a, b) => (a, b) =>
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length, Object.keys(groupByExam(getStatsByStudent(b))).length -
Object.keys(groupByExam(getStatsByStudent(a))).length
) )
.map((x) => ( .map((x) => (
<UserDisplay key={x.id} {...x} /> <UserDisplay key={x.id} {...x} />
@@ -584,7 +740,8 @@ export default function CorporateDashboard({user}: Props) {
if (shouldReload) reload(); if (shouldReload) reload();
}} }}
onViewStudents={ onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "teacher" selectedUser.type === "corporate" ||
selectedUser.type === "teacher"
? () => { ? () => {
appendUserFilters({ appendUserFilters({
id: "view-students", id: "view-students",
@@ -594,7 +751,11 @@ export default function CorporateDashboard({user}: Props) {
id: "belongs-to-admin", id: "belongs-to-admin",
filter: (x: User) => filter: (x: User) =>
groups groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id)) .filter(
(g) =>
g.admin === selectedUser.id ||
g.participants.includes(selectedUser.id)
)
.flatMap((g) => g.participants) .flatMap((g) => g.participants)
.includes(x.id), .includes(x.id),
}); });
@@ -604,7 +765,8 @@ export default function CorporateDashboard({user}: Props) {
: undefined : undefined
} }
onViewTeachers={ onViewTeachers={
selectedUser.type === "corporate" || selectedUser.type === "student" selectedUser.type === "corporate" ||
selectedUser.type === "student"
? () => { ? () => {
appendUserFilters({ appendUserFilters({
id: "view-teachers", id: "view-teachers",
@@ -614,7 +776,11 @@ export default function CorporateDashboard({user}: Props) {
id: "belongs-to-admin", id: "belongs-to-admin",
filter: (x: User) => filter: (x: User) =>
groups groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id)) .filter(
(g) =>
g.admin === selectedUser.id ||
g.participants.includes(selectedUser.id)
)
.flatMap((g) => g.participants) .flatMap((g) => g.participants)
.includes(x.id), .includes(x.id),
}); });

View File

@@ -6,17 +6,27 @@ interface Props {
label: string; label: string;
value?: string | number; value?: string | number;
color: "purple" | "rose" | "red" | "green"; color: "purple" | "rose" | "red" | "green";
className?: string;
tooltip?: string; tooltip?: string;
onClick?: () => void; onClick?: () => void;
isSelected?: boolean;
className?: string;
} }
export default function IconCard({Icon, label, value, color, tooltip, className, onClick}: Props) { export default function IconCard({
Icon,
label,
value,
color,
tooltip,
onClick,
className,
isSelected,
}: Props) {
const colorClasses: { [key in typeof color]: string } = { const colorClasses: { [key in typeof color]: string } = {
purple: "text-mti-purple-light", purple: "mti-purple-light",
red: "text-mti-red-light", red: "mti-red-light",
rose: "text-mti-rose-light", rose: "mti-rose-light",
green: "text-mti-green-light", green: "mti-green-light",
}; };
return ( return (
@@ -25,13 +35,17 @@ export default function IconCard({Icon, label, value, color, tooltip, className,
className={clsx( className={clsx(
"bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center text-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300", "bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center text-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300",
tooltip && "tooltip tooltip-bottom", tooltip && "tooltip tooltip-bottom",
isSelected && `border border-solid border-${colorClasses[color]}`,
className, className,
)} )}
data-tip={tooltip}> data-tip={tooltip}
<Icon className={clsx("text-6xl", colorClasses[color])} /> >
<Icon className={clsx("text-6xl", `text-${colorClasses[color]}`)} />
<span className="flex flex-col gap-1 items-center text-xl"> <span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">{label}</span> <span className="text-lg">{label}</span>
<span className={clsx("font-semibold", colorClasses[color])}>{value}</span> <span className={clsx("font-semibold", `text-${colorClasses[color]}`)}>
{value}
</span>
</span> </span>
</div> </div>
); );

View File

@@ -2,11 +2,17 @@
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import {CorporateUser, Group, MasterCorporateUser, Stat, User} from "@/interfaces/user"; import {
CorporateUser,
Group,
MasterCorporateUser,
Stat,
User,
} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList"; import UserList from "@/pages/(admin)/Lists/UserList";
import { dateSorter } from "@/utils"; import { dateSorter } from "@/utils";
import moment from "moment"; import moment from "moment";
import {useEffect, useState} from "react"; import { useEffect, useState, useMemo } from "react";
import { import {
BsArrowLeft, BsArrowLeft,
BsClipboard2Data, BsClipboard2Data,
@@ -27,7 +33,11 @@ import {
import UserCard from "@/components/UserCard"; import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score"; import {
averageLevelCalculator,
calculateAverageLevel,
calculateBandScore,
} from "@/utils/score";
import { MODULE_ARRAY } from "@/utils/moduleUtils"; import { MODULE_ARRAY } from "@/utils/moduleUtils";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { groupByExam } from "@/utils/stats"; import { groupByExam } from "@/utils/stats";
@@ -50,38 +60,59 @@ import Checkbox from "@/components/Low/Checkbox";
import { groupBy, uniq, uniqBy } from "lodash"; import { groupBy, uniq, uniqBy } from "lodash";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover"; import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import MasterStatistical from "./MasterStatistical"; import MasterStatistical from "./MasterStatistical";
import {
futureAssignmentFilter,
pastAssignmentFilter,
archivedAssignmentFilter,
activeAssignmentFilter,
} from "@/utils/assignments";
import useUserBalance from "@/hooks/useUserBalance"; import useUserBalance from "@/hooks/useUserBalance";
interface Props { interface Props {
user: MasterCorporateUser; user: MasterCorporateUser;
} }
const activeFilter = (a: Assignment) => type StudentPerformanceItem = User & {
moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length; corporate?: CorporateUser;
const pastFilter = (a: Assignment) => (moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length) && !a.archived; group?: Group;
const archivedFilter = (a: Assignment) => a.archived; };
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment()); const StudentPerformanceList = ({
items,
type StudentPerformanceItem = User & {corporate?: CorporateUser; group?: Group}; stats,
const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPerformanceItem[]; stats: Stat[]; users: User[]; groups: Group[]}) => { users,
groups,
}: {
items: StudentPerformanceItem[];
stats: Stat[];
users: User[];
groups: Group[];
}) => {
const [isShowingAmount, setIsShowingAmount] = useState(false); const [isShowingAmount, setIsShowingAmount] = useState(false);
const [availableCorporates] = useState( const [availableCorporates] = useState(
uniqBy( uniqBy(
items.map((x) => x.corporate), items.map((x) => x.corporate),
"id", "id"
), )
); );
const [availableGroups] = useState( const [availableGroups] = useState(
uniqBy( uniqBy(
items.map((x) => x.group), items.map((x) => x.group),
"id", "id"
), )
); );
const [selectedCorporate, setSelectedCorporate] = useState<CorporateUser | null | undefined>(null); const [selectedCorporate, setSelectedCorporate] = useState<
const [selectedGroup, setSelectedGroup] = useState<Group | null | undefined>(null); CorporateUser | null | undefined
>(null);
const [selectedGroup, setSelectedGroup] = useState<Group | null | undefined>(
null
);
const columnHelper = createColumnHelper<StudentPerformanceItem>(); const columnHelper = createColumnHelper<StudentPerformanceItem>();
@@ -104,7 +135,10 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
}), }),
columnHelper.accessor("corporate", { columnHelper.accessor("corporate", {
header: "Corporate", header: "Corporate",
cell: (info) => (!!info.getValue() ? getUserCompanyName(info.getValue() as User, users, groups) : "N/A"), cell: (info) =>
!!info.getValue()
? getUserCompanyName(info.getValue() as User, users, groups)
: "N/A",
}), }),
columnHelper.accessor("levels.reading", { columnHelper.accessor("levels.reading", {
header: "Reading", header: "Reading",
@@ -112,15 +146,30 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
!isShowingAmount !isShowingAmount
? calculateBandScore( ? calculateBandScore(
stats stats
.filter((x) => x.module === "reading" && x.user === info.row.original.id) .filter(
(x) =>
x.module === "reading" && x.user === info.row.original.id
)
.reduce((acc, curr) => acc + curr.score.correct, 0), .reduce((acc, curr) => acc + curr.score.correct, 0),
stats stats
.filter((x) => x.module === "reading" && x.user === info.row.original.id) .filter(
(x) =>
x.module === "reading" && x.user === info.row.original.id
)
.reduce((acc, curr) => acc + curr.score.total, 0), .reduce((acc, curr) => acc + curr.score.total, 0),
"level", "level",
info.row.original.focus || "academic", info.row.original.focus || "academic"
) || 0 ) || 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", { columnHelper.accessor("levels.listening", {
header: "Listening", header: "Listening",
@@ -128,15 +177,31 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
!isShowingAmount !isShowingAmount
? calculateBandScore( ? calculateBandScore(
stats stats
.filter((x) => x.module === "listening" && x.user === info.row.original.id) .filter(
(x) =>
x.module === "listening" && x.user === info.row.original.id
)
.reduce((acc, curr) => acc + curr.score.correct, 0), .reduce((acc, curr) => acc + curr.score.correct, 0),
stats stats
.filter((x) => x.module === "listening" && x.user === info.row.original.id) .filter(
(x) =>
x.module === "listening" && x.user === info.row.original.id
)
.reduce((acc, curr) => acc + curr.score.total, 0), .reduce((acc, curr) => acc + curr.score.total, 0),
"level", "level",
info.row.original.focus || "academic", info.row.original.focus || "academic"
) || 0 ) || 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", { columnHelper.accessor("levels.writing", {
header: "Writing", header: "Writing",
@@ -144,15 +209,30 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
!isShowingAmount !isShowingAmount
? calculateBandScore( ? calculateBandScore(
stats stats
.filter((x) => x.module === "writing" && x.user === info.row.original.id) .filter(
(x) =>
x.module === "writing" && x.user === info.row.original.id
)
.reduce((acc, curr) => acc + curr.score.correct, 0), .reduce((acc, curr) => acc + curr.score.correct, 0),
stats stats
.filter((x) => x.module === "writing" && x.user === info.row.original.id) .filter(
(x) =>
x.module === "writing" && x.user === info.row.original.id
)
.reduce((acc, curr) => acc + curr.score.total, 0), .reduce((acc, curr) => acc + curr.score.total, 0),
"level", "level",
info.row.original.focus || "academic", info.row.original.focus || "academic"
) || 0 ) || 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", { columnHelper.accessor("levels.speaking", {
header: "Speaking", header: "Speaking",
@@ -160,15 +240,30 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
!isShowingAmount !isShowingAmount
? calculateBandScore( ? calculateBandScore(
stats stats
.filter((x) => x.module === "speaking" && x.user === info.row.original.id) .filter(
(x) =>
x.module === "speaking" && x.user === info.row.original.id
)
.reduce((acc, curr) => acc + curr.score.correct, 0), .reduce((acc, curr) => acc + curr.score.correct, 0),
stats stats
.filter((x) => x.module === "speaking" && x.user === info.row.original.id) .filter(
(x) =>
x.module === "speaking" && x.user === info.row.original.id
)
.reduce((acc, curr) => acc + curr.score.total, 0), .reduce((acc, curr) => acc + curr.score.total, 0),
"level", "level",
info.row.original.focus || "academic", info.row.original.focus || "academic"
) || 0 ) || 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", { columnHelper.accessor("levels.level", {
header: "Level", header: "Level",
@@ -176,15 +271,28 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
!isShowingAmount !isShowingAmount
? calculateBandScore( ? calculateBandScore(
stats stats
.filter((x) => x.module === "level" && x.user === info.row.original.id) .filter(
(x) => x.module === "level" && x.user === info.row.original.id
)
.reduce((acc, curr) => acc + curr.score.correct, 0), .reduce((acc, curr) => acc + curr.score.correct, 0),
stats stats
.filter((x) => x.module === "level" && x.user === info.row.original.id) .filter(
(x) => x.module === "level" && x.user === info.row.original.id
)
.reduce((acc, curr) => acc + curr.score.total, 0), .reduce((acc, curr) => acc + curr.score.total, 0),
"level", "level",
info.row.original.focus || "academic", info.row.original.focus || "academic"
) || 0 ) || 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", { columnHelper.accessor("levels", {
id: "overall_level", id: "overall_level",
@@ -193,16 +301,24 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
!isShowingAmount !isShowingAmount
? averageLevelCalculator( ? averageLevelCalculator(
users, users,
stats.filter((x) => x.user === info.row.original.id), stats.filter((x) => x.user === info.row.original.id)
).toFixed(1) ).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`,
}), }),
]; ];
const filterUsers = (data: StudentPerformanceItem[]) => { const filterUsers = (data: StudentPerformanceItem[]) => {
console.log(data, selectedCorporate); console.log(data, selectedCorporate);
const filterByCorporate = (item: StudentPerformanceItem) => item.corporate?.id === selectedCorporate?.id; const filterByCorporate = (item: StudentPerformanceItem) =>
const filterByGroup = (item: StudentPerformanceItem) => item.group?.id === selectedGroup?.id; item.corporate?.id === selectedCorporate?.id;
const filterByGroup = (item: StudentPerformanceItem) =>
item.group?.id === selectedGroup?.id;
const filters: ((item: StudentPerformanceItem) => boolean)[] = []; const filters: ((item: StudentPerformanceItem) => boolean)[] = [];
if (selectedCorporate !== null) filters.push(filterByCorporate); if (selectedCorporate !== null) filters.push(filterByCorporate);
@@ -229,7 +345,10 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
<Select <Select
options={availableCorporates.map((x) => ({ options={availableCorporates.map((x) => ({
value: x?.id || "N/A", value: x?.id || "N/A",
label: x?.corporateInformation?.companyInformation?.name || x?.name || "N/A", label:
x?.corporateInformation?.companyInformation?.name ||
x?.name ||
"N/A",
}))} }))}
isClearable isClearable
value={ value={
@@ -238,7 +357,8 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
: { : {
value: selectedCorporate?.id || "N/A", value: selectedCorporate?.id || "N/A",
label: label:
selectedCorporate?.corporateInformation?.companyInformation?.name || selectedCorporate?.corporateInformation
?.companyInformation?.name ||
selectedCorporate?.name || selectedCorporate?.name ||
"N/A", "N/A",
} }
@@ -248,7 +368,11 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
!value !value
? setSelectedCorporate(null) ? setSelectedCorporate(null)
: setSelectedCorporate( : setSelectedCorporate(
value.value === "N/A" ? undefined : availableCorporates.find((x) => x?.id === value.value), value.value === "N/A"
? undefined
: availableCorporates.find(
(x) => x?.id === value.value
)
) )
} }
/> />
@@ -270,7 +394,11 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
onChange={(value) => onChange={(value) =>
!value !value
? setSelectedGroup(null) ? setSelectedGroup(null)
: setSelectedGroup(value.value === "N/A" ? undefined : availableGroups.find((x) => x?.id === value.value)) : setSelectedGroup(
value.value === "N/A"
? undefined
: availableGroups.find((x) => x?.id === value.value)
)
} }
/> />
</div> </div>
@@ -283,13 +411,13 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
(a, b) => (a, b) =>
averageLevelCalculator( averageLevelCalculator(
users, users,
stats.filter((x) => x.user === b.id), stats.filter((x) => x.user === b.id)
) - ) -
averageLevelCalculator( averageLevelCalculator(
users, users,
stats.filter((x) => x.user === a.id), stats.filter((x) => x.user === a.id)
), )
), )
)} )}
columns={columns} columns={columns}
/> />
@@ -303,18 +431,31 @@ export default function MasterCorporateDashboard({user}: Props) {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>(); const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false); const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
const [corporateAssignments, setCorporateAssignments] = useState<(Assignment & {corporate?: CorporateUser})[]>([]); const [corporateAssignments, setCorporateAssignments] = useState<
(Assignment & { corporate?: CorporateUser })[]
>([]);
const { stats } = useStats(); const { stats } = useStats();
const { users, reload } = useUsers(); const { users, reload } = useUsers();
const { codes } = useCodes(user.id);
const { groups } = useGroups({ admin: user.id, userType: user.type }); const { groups } = useGroups({ admin: user.id, userType: user.type });
const { balance } = useUserBalance(); const { balance } = useUserBalance();
const masterCorporateUserGroups = [...new Set(groups.filter((u) => u.admin === user.id).flatMap((g) => g.participants))]; const masterCorporateUserGroups = useMemo(() => [
...new Set(
groups.filter((u) => u.admin === user.id).flatMap((g) => g.participants)
),
], [groups, user.id]);
const corporateUserGroups = [...new Set(groups.flatMap((g) => g.participants))]; const corporateUserGroups = [
...new Set(groups.flatMap((g) => g.participants)),
];
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id}); const {
assignments,
isLoading: isAssignmentsLoading,
reload: reloadAssignments,
} = useAssignments({ corporate: user.id });
const appendUserFilters = useFilterStore((state) => state.appendUserFilter); const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter(); const router = useRouter();
@@ -325,24 +466,36 @@ export default function MasterCorporateDashboard({user}: Props) {
useEffect(() => { useEffect(() => {
setCorporateAssignments( setCorporateAssignments(
assignments.filter(activeFilter).map((a) => ({ assignments.filter(activeAssignmentFilter).map((a) => ({
...a, ...a,
corporate: !!users.find((x) => x.id === a.assigner) corporate: !!users.find((x) => x.id === a.assigner)
? getCorporateUser(users.find((x) => x.id === a.assigner)!, users, groups) ? getCorporateUser(
users.find((x) => x.id === a.assigner)!,
users,
groups
)
: undefined, : undefined,
})), }))
); );
}, [assignments, groups, users]); }, [assignments, groups, users]);
const studentFilter = (user: User) => user.type === "student" && corporateUserGroups.includes(user.id); const studentFilter = (user: User) =>
const teacherFilter = (user: User) => user.type === "teacher" && corporateUserGroups.includes(user.id); user.type === "student" && corporateUserGroups.includes(user.id);
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id); const teacherFilter = (user: User) =>
user.type === "teacher" && corporateUserGroups.includes(user.id);
const getStatsByStudent = (user: User) =>
stats.filter((s) => s.user === user.id);
const UserDisplay = (displayUser: User) => ( const UserDisplay = (displayUser: User) => (
<div <div
onClick={() => setSelectedUser(displayUser)} 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"> 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" /> >
<img
src={displayUser.profilePicture}
alt={displayUser.name}
className="rounded-full w-10 h-10"
/>
<div className="flex flex-col gap-1 items-start"> <div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span> <span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span> <span className="text-sm opacity-75">{displayUser.email}</span>
@@ -352,7 +505,10 @@ export default function MasterCorporateDashboard({user}: Props) {
const StudentsList = () => { const StudentsList = () => {
const filter = (x: User) => const filter = (x: User) =>
x.type === "student" && (!!selectedUser ? corporateUserGroups.includes(x.id) || false : corporateUserGroups.includes(x.id)); x.type === "student" &&
(!!selectedUser
? corporateUserGroups.includes(x.id) || false
: corporateUserGroups.includes(x.id));
return ( return (
<UserList <UserList
@@ -362,7 +518,8 @@ export default function MasterCorporateDashboard({user}: Props) {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
</div> </div>
@@ -375,7 +532,10 @@ export default function MasterCorporateDashboard({user}: Props) {
const TeachersList = () => { const TeachersList = () => {
const filter = (x: User) => const filter = (x: User) =>
x.type === "teacher" && (!!selectedUser ? corporateUserGroups.includes(x.id) || false : corporateUserGroups.includes(x.id)); x.type === "teacher" &&
(!!selectedUser
? corporateUserGroups.includes(x.id) || false
: corporateUserGroups.includes(x.id));
return ( return (
<UserList <UserList
@@ -385,7 +545,8 @@ export default function MasterCorporateDashboard({user}: Props) {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
</div> </div>
@@ -397,7 +558,10 @@ export default function MasterCorporateDashboard({user}: Props) {
}; };
const corporateUserFilter = (x: User) => const corporateUserFilter = (x: User) =>
x.type === "corporate" && (!!selectedUser ? masterCorporateUserGroups.includes(x.id) || false : masterCorporateUserGroups.includes(x.id)); x.type === "corporate" &&
(!!selectedUser
? masterCorporateUserGroups.includes(x.id) || false
: masterCorporateUserGroups.includes(x.id));
const CorporateList = () => { const CorporateList = () => {
return ( return (
@@ -408,7 +572,8 @@ export default function MasterCorporateDashboard({user}: Props) {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
</div> </div>
@@ -425,7 +590,8 @@ export default function MasterCorporateDashboard({user}: Props) {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
</div> </div>
@@ -437,22 +603,13 @@ 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 StudentPerformancePage = () => {
const students = users 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) => ({ .map((u) => ({
...u, ...u,
group: groups.find((x) => x.participants.includes(u.id)), group: groups.find((x) => x.participants.includes(u.id)),
@@ -464,18 +621,30 @@ export default function MasterCorporateDashboard({user}: Props) {
<div className="w-full flex justify-between items-center"> <div className="w-full flex justify-between items-center">
<div <div
onClick={() => setPage("")} 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
</div> </div>
<div <div
onClick={reloadAssignments} 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> <span>Reload</span>
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} /> <BsArrowRepeat
className={clsx(
"text-xl",
isAssignmentsLoading && "animate-spin"
)}
/>
</div> </div>
</div> </div>
<StudentPerformanceList items={students} stats={stats} users={users} groups={groups} /> <StudentPerformanceList
items={students}
stats={stats}
users={users}
groups={groups}
/>
</> </>
); );
}; };
@@ -494,7 +663,9 @@ export default function MasterCorporateDashboard({user}: Props) {
/> />
<AssignmentCreator <AssignmentCreator
assignment={selectedAssignment} 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( users={users.filter(
(x) => (x) =>
x.type === "student" && x.type === "student" &&
@@ -503,7 +674,7 @@ export default function MasterCorporateDashboard({user}: Props) {
.filter((g) => g.admin === selectedUser.id) .filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants) .flatMap((g) => g.participants)
.includes(x.id) || false .includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id)), : groups.flatMap((g) => g.participants).includes(x.id))
)} )}
assigner={user.id} assigner={user.id}
isCreating={isCreatingAssignment} isCreating={isCreatingAssignment}
@@ -516,53 +687,92 @@ export default function MasterCorporateDashboard({user}: Props) {
<div className="w-full flex justify-between items-center"> <div className="w-full flex justify-between items-center">
<div <div
onClick={() => setPage("")} 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
</div> </div>
<div <div
onClick={reloadAssignments} 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> <span>Reload</span>
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} /> <BsArrowRepeat
className={clsx(
"text-xl",
isAssignmentsLoading && "animate-spin"
)}
/>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-lg font-bold">Active Assignments Status</span> <span className="text-lg font-bold">Active Assignments Status</span>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span> <span>
<b>Total:</b> {assignments.filter(activeFilter).reduce((acc, curr) => acc + curr.results.length, 0)}/ <b>Total:</b>{" "}
{assignments.filter(activeFilter).reduce((acc, curr) => curr.exams.length + acc, 0)} {assignments
.filter(activeAssignmentFilter)
.reduce((acc, curr) => acc + curr.results.length, 0)}
/
{assignments
.filter(activeAssignmentFilter)
.reduce((acc, curr) => curr.exams.length + acc, 0)}
</span> </span>
{Object.keys(groupBy(corporateAssignments, (x) => x.corporate?.id)).map((x) => ( {Object.keys(
groupBy(corporateAssignments, (x) => x.corporate?.id)
).map((x) => (
<div key={x}> <div key={x}>
<span className="font-semibold">{getUserCompanyName(users.find((u) => u.id === x)!, users, groups)}: </span> <span className="font-semibold">
{getUserCompanyName(
users.find((u) => u.id === x)!,
users,
groups
)}
:{" "}
</span>
<span> <span>
{groupBy(corporateAssignments, (x) => x.corporate?.id)[x].reduce((acc, curr) => curr.results.length + acc, 0)}/ {groupBy(corporateAssignments, (x) => x.corporate?.id)[
{groupBy(corporateAssignments, (x) => x.corporate?.id)[x].reduce((acc, curr) => curr.exams.length + acc, 0)} x
].reduce((acc, curr) => curr.results.length + acc, 0)}
/
{groupBy(corporateAssignments, (x) => x.corporate?.id)[
x
].reduce((acc, curr) => curr.exams.length + acc, 0)}
</span> </span>
</div> </div>
))} ))}
</div> </div>
</div> </div>
<section className="flex flex-col gap-4"> <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"> <div className="flex flex-wrap gap-2">
{assignments.filter(activeFilter).map((a) => ( {assignments.filter(activeAssignmentFilter).map((a) => (
<AssignmentCard {...a} users={users} onClick={() => setSelectedAssignment(a)} key={a.id} /> <AssignmentCard
{...a}
users={users}
onClick={() => setSelectedAssignment(a)}
key={a.id}
/>
))} ))}
</div> </div>
</section> </section>
<section className="flex flex-col gap-4"> <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 className="flex flex-wrap gap-2">
<div <div
onClick={() => setIsCreatingAssignment(true)} 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" /> <BsPlus className="text-6xl" />
<span className="text-lg">New Assignment</span> <span className="text-lg">New Assignment</span>
</div> </div>
{assignments.filter(futureFilter).map((a) => ( {assignments.filter(futureAssignmentFilter).map((a) => (
<AssignmentCard <AssignmentCard
{...a} {...a}
users={users} users={users}
@@ -576,9 +786,11 @@ export default function MasterCorporateDashboard({user}: Props) {
</div> </div>
</section> </section>
<section className="flex flex-col gap-4"> <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"> <div className="flex flex-wrap gap-2">
{assignments.filter(pastFilter).map((a) => ( {assignments.filter(pastAssignmentFilter).map((a) => (
<AssignmentCard <AssignmentCard
{...a} {...a}
users={users} users={users}
@@ -593,9 +805,12 @@ export default function MasterCorporateDashboard({user}: Props) {
</div> </div>
</section> </section>
<section className="flex flex-col gap-4"> <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"> <div className="flex flex-wrap gap-2">
{assignments.filter(archivedFilter).map((a) => ( {assignments.filter(archivedAssignmentFilter).map((a) => (
<AssignmentCard <AssignmentCard
{...a} {...a}
users={users} users={users}
@@ -613,24 +828,32 @@ export default function MasterCorporateDashboard({user}: Props) {
); );
}; };
const masterCorporateUsers = useMemo(
() =>
masterCorporateUserGroups.reduce((accm: CorporateUser[], id) => {
const user = users.find((u) => u.id === id) as CorporateUser;
if (user) return [...accm, user];
return accm;
}, []),
[masterCorporateUserGroups, users]
);
const MasterStatisticalPage = () => { const MasterStatisticalPage = () => {
return ( return (
<> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} onClick={() => 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
</div> </div>
<h2 className="text-2xl font-semibold">Master Statistical</h2> <h2 className="text-2xl font-semibold">Master Statistical</h2>
</div> </div>
<MasterStatistical <MasterStatistical
users={masterCorporateUserGroups.reduce((accm: CorporateUser[], id) => { users={users}
const user = users.find((u) => u.id === id) as CorporateUser; corporateUsers={masterCorporateUsers}
if (user) return [...accm, user];
return accm;
}, [])}
/> />
</> </>
); );
@@ -656,7 +879,11 @@ export default function MasterCorporateDashboard({user}: Props) {
<IconCard <IconCard
Icon={BsClipboard2Data} Icon={BsClipboard2Data}
label="Exams Performed" 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" color="purple"
/> />
<IconCard <IconCard
@@ -664,21 +891,35 @@ export default function MasterCorporateDashboard({user}: Props) {
label="Average Level" label="Average Level"
value={averageLevelCalculator( value={averageLevelCalculator(
users, users,
stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)), stats.filter((s) =>
groups.flatMap((g) => g.participants).includes(s.user)
)
).toFixed(1)} ).toFixed(1)}
color="purple" color="purple"
/> />
<IconCard onClick={() => setPage("groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" /> <IconCard
onClick={() => setPage("groups")}
Icon={BsPeople}
label="Groups"
value={groups.length}
color="purple"
/>
<IconCard <IconCard
Icon={BsPersonCheck} Icon={BsPersonCheck}
label="User Balance" label="User Balance"
value={`${balance}/${user.corporateInformation?.companyInformation?.userAmount || 0}`} value={`${balance}/${
user.corporateInformation?.companyInformation?.userAmount || 0
}`}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsClock} Icon={BsClock}
label="Expiration Date" 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" color="rose"
/> />
<IconCard <IconCard
@@ -695,22 +936,25 @@ export default function MasterCorporateDashboard({user}: Props) {
color="purple" color="purple"
onClick={() => setPage("studentsPerformance")} onClick={() => setPage("studentsPerformance")}
/> />
{/* <IconCard <IconCard
Icon={BsDatabase} Icon={BsDatabase}
label="Master Statistical" label="Master Statistical"
// value={masterCorporateUserGroups.length} // value={masterCorporateUserGroups.length}
color="purple" color="purple"
onClick={() => setPage("statistical")} onClick={() => setPage("statistical")}
/> */} />
<button <button
disabled={isAssignmentsLoading} disabled={isAssignmentsLoading}
onClick={() => setPage("assignments")} 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" /> <BsEnvelopePaper className="text-6xl text-mti-purple-light" />
<span className="flex flex-col gap-1 items-center text-xl"> <span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">Assignments</span> <span className="text-lg">Assignments</span>
<span className="font-semibold text-mti-purple-light"> <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>
</span> </span>
</button> </button>
@@ -744,7 +988,11 @@ export default function MasterCorporateDashboard({user}: Props) {
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {users
.filter(studentFilter) .filter(studentFilter)
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels)) .sort(
(a, b) =>
calculateAverageLevel(b.levels) -
calculateAverageLevel(a.levels)
)
.map((x) => ( .map((x) => (
<UserDisplay key={x.id} {...x} /> <UserDisplay key={x.id} {...x} />
))} ))}
@@ -757,7 +1005,8 @@ export default function MasterCorporateDashboard({user}: Props) {
.filter(studentFilter) .filter(studentFilter)
.sort( .sort(
(a, b) => (a, b) =>
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length, Object.keys(groupByExam(getStatsByStudent(b))).length -
Object.keys(groupByExam(getStatsByStudent(a))).length
) )
.map((x) => ( .map((x) => (
<UserDisplay key={x.id} {...x} /> <UserDisplay key={x.id} {...x} />
@@ -777,7 +1026,8 @@ export default function MasterCorporateDashboard({user}: Props) {
<UserCard <UserCard
maxUserAmount={ maxUserAmount={
user.type === "mastercorporate" user.type === "mastercorporate"
? (user.corporateInformation?.companyInformation?.userAmount || 0) - balance ? (user.corporateInformation?.companyInformation
?.userAmount || 0) - balance
: undefined : undefined
} }
loggedInUser={user} loggedInUser={user}
@@ -786,7 +1036,8 @@ export default function MasterCorporateDashboard({user}: Props) {
if (shouldReload) reload(); if (shouldReload) reload();
}} }}
onViewStudents={ onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "teacher" selectedUser.type === "corporate" ||
selectedUser.type === "teacher"
? () => { ? () => {
appendUserFilters({ appendUserFilters({
id: "view-students", id: "view-students",
@@ -796,7 +1047,11 @@ export default function MasterCorporateDashboard({user}: Props) {
id: "belongs-to-admin", id: "belongs-to-admin",
filter: (x: User) => filter: (x: User) =>
groups groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id)) .filter(
(g) =>
g.admin === selectedUser.id ||
g.participants.includes(selectedUser.id)
)
.flatMap((g) => g.participants) .flatMap((g) => g.participants)
.includes(x.id), .includes(x.id),
}); });
@@ -806,7 +1061,8 @@ export default function MasterCorporateDashboard({user}: Props) {
: undefined : undefined
} }
onViewTeachers={ onViewTeachers={
selectedUser.type === "corporate" || selectedUser.type === "student" selectedUser.type === "corporate" ||
selectedUser.type === "student"
? () => { ? () => {
appendUserFilters({ appendUserFilters({
id: "view-teachers", id: "view-teachers",
@@ -816,7 +1072,11 @@ export default function MasterCorporateDashboard({user}: Props) {
id: "belongs-to-admin", id: "belongs-to-admin",
filter: (x: User) => filter: (x: User) =>
groups groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id)) .filter(
(g) =>
g.admin === selectedUser.id ||
g.participants.includes(selectedUser.id)
)
.flatMap((g) => g.participants) .flatMap((g) => g.participants)
.includes(x.id), .includes(x.id),
}); });

View File

@@ -1,33 +1,341 @@
import React from "react"; import React from "react";
import {CorporateUser} from "@/interfaces/user"; import { CorporateUser, User } from "@/interfaces/user";
import { BsBank, BsPersonFill } from "react-icons/bs"; import { BsBank, BsPersonFill } from "react-icons/bs";
import IconCard from "./IconCard"; import IconCard from "./IconCard";
import useAssignmentsCorporates from "@/hooks/useAssignmentCorporates"; import useAssignmentsCorporates from "@/hooks/useAssignmentCorporates";
import ReactDatePicker from "react-datepicker";
import moment from "moment";
import { Assignment, AssignmentWithCorporateId } from "@/interfaces/results";
import {
CellContext,
createColumnHelper,
flexRender,
getCoreRowModel,
HeaderGroup,
Table,
useReactTable,
} from "@tanstack/react-table";
import Checkbox from "@/components/Low/Checkbox";
interface Props { interface Props {
users: CorporateUser[]; corporateUsers: User[];
users: User[];
} }
interface TableData {
user: string;
correct: number;
corporate: string;
submitted: boolean;
date: moment.Moment;
assignment: string;
corporateId: string;
}
interface UserCount {
userCount: number;
maxUserCount: number;
}
const MasterStatistical = (props: Props) => { const MasterStatistical = (props: Props) => {
const {users} = props; const { users, corporateUsers } = props;
const usersList = React.useMemo(() => users.map((x) => x.id), [users]); const corporateRelevantUsers = React.useMemo(
() => corporateUsers.filter((x) => x.type !== "student") as CorporateUser[],
[corporateUsers]
);
const {assignments} = useAssignmentsCorporates({corporates: usersList}); const corporates = React.useMemo(
() => corporateRelevantUsers.map((x) => x.id),
[corporateRelevantUsers]
);
const [selectedCorporates, setSelectedCorporates] =
React.useState<string[]>(corporates);
const [startDate, setStartDate] = React.useState<Date | null>(
moment("01/01/2023").toDate()
);
const [endDate, setEndDate] = React.useState<Date | null>(
moment().endOf("year").toDate()
);
const { assignments } = useAssignmentsCorporates({
// corporates: [...corporates, "tYU0HTiJdjMsS8SB7XJsUdMMP892"],
corporates: selectedCorporates,
startDate,
endDate,
});
const tableResults = React.useMemo(
() =>
assignments.reduce((accmA: TableData[], a: AssignmentWithCorporateId) => {
const userResults = a.assignees.map((assignee) => {
const userStats =
a.results.find((r) => r.user === assignee)?.stats || [];
const userName = users.find((u) => u.id === assignee)?.name || "";
const corporate = users.find((u) => u.id === a.assigner)?.name || "";
const commonData = {
user: userName,
corporateId: a.corporateId,
corporate,
assignment: a.name,
};
if (userStats.length === 0) {
return {
...commonData,
correct: 0,
submitted: false,
// date: moment(),
};
}
return {
...commonData,
correct: userStats.reduce((n, e) => n + e.score.correct, 0),
submitted: true,
date: moment.max(userStats.map((e) => moment(e.date))),
};
}) as TableData[];
return [...accmA, ...userResults];
}, []),
[assignments, users]
);
const getCorporateScores = (corporateId: string): UserCount => {
const corporateAssignmentsUsers = assignments
.filter((a) => a.corporateId === corporateId)
.reduce((acc, a) => acc + a.assignees.length, 0);
const corporateResults = tableResults.filter(
(r) => r.corporateId === corporateId
).length;
return {
maxUserCount: corporateAssignmentsUsers,
userCount: corporateResults,
};
};
const corporateScores = corporates.reduce(
(accm, id) => ({
...accm,
[id]: getCorporateScores(id),
}),
{}
) as Record<string, UserCount>;
const consolidateScore = Object.values(corporateScores).reduce(
(acc: UserCount, { userCount, maxUserCount }: UserCount) => ({
userCount: acc.userCount + userCount,
maxUserCount: acc.maxUserCount + maxUserCount,
}),
{ userCount: 0, maxUserCount: 0 }
);
const getConsolidateScoreStr = (data: UserCount) =>
`${data.userCount}/${data.maxUserCount}`;
const columnHelper = createColumnHelper<TableData>();
const defaultColumns = [
columnHelper.accessor("user", {
header: "User",
id: "user",
cell: (info) => {
return <span>{info.getValue()}</span>;
},
}),
columnHelper.accessor("corporate", {
header: "Corporate",
id: "corporate",
cell: (info) => {
return <span>{info.getValue()}</span>;
},
}),
columnHelper.accessor("assignment", {
header: "Assignment",
id: "assignment",
cell: (info) => {
return <span>{info.getValue()}</span>;
},
}),
columnHelper.accessor("submitted", {
header: "Submitted",
id: "submitted",
cell: (info) => {
return ( return (
<Checkbox isChecked={info.getValue()} disabled onChange={() => {}}>
<span></span>
</Checkbox>
);
},
}),
columnHelper.accessor("correct", {
header: "Correct",
id: "correct",
cell: (info) => {
return <span>{info.getValue()}</span>;
},
}),
columnHelper.accessor("date", {
header: "Date",
id: "date",
cell: (info) => {
const date = info.getValue();
if (date) {
return <span>{date.format("DD/MM/YYYY")}</span>;
}
return <span>{""}</span>;
},
}),
];
const table = useReactTable({
data: tableResults,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
const areAllSelected = selectedCorporates.length === corporates.length;
const getStudentsConsolidateScore = () => {
if (tableResults.length === 0) {
return { highest: null, lowest: null };
}
// Find the student with the highest and lowest score
return tableResults.reduce(
(acc, curr) => {
if (curr.correct > acc.highest.correct) {
acc.highest = curr;
}
if (curr.correct < acc.lowest.correct) {
acc.lowest = curr;
}
return acc;
},
{ highest: tableResults[0], lowest: tableResults[0] }
);
};
const consolidateResults = getStudentsConsolidateScore();
return (
<>
<div className="flex flex-wrap gap-2 items-center text-center"> <div className="flex flex-wrap gap-2 items-center text-center">
<IconCard Icon={BsBank} label="Consolidate" value={0} color="purple" onClick={() => console.log("clicked")} /> <IconCard
{users.map((group) => ( Icon={BsBank}
label="Consolidate"
value={getConsolidateScoreStr(consolidateScore)}
color="purple"
onClick={() => {
if (areAllSelected) {
setSelectedCorporates([]);
return;
}
setSelectedCorporates(corporates);
}}
isSelected={areAllSelected}
/>
{corporateRelevantUsers.map((group) => {
const isSelected = selectedCorporates.includes(group.id);
return (
<IconCard <IconCard
key={group.id} key={group.id}
Icon={BsBank} Icon={BsBank}
label={group.corporateInformation?.companyInformation?.name} label={group.corporateInformation?.companyInformation?.name}
value={0} value={getConsolidateScoreStr(corporateScores[group.id])}
color="purple" color="purple"
onClick={() => console.log("clicked", group)} onClick={() => {
if (isSelected) {
setSelectedCorporates(
selectedCorporates.filter((x) => x !== group.id)
);
return;
}
setSelectedCorporates([...selectedCorporates, group.id]);
}}
isSelected={isSelected}
/> />
))} );
<IconCard onClick={() => console.log("clicked")} Icon={BsPersonFill} label="Consolidate Highest Student" color="purple" /> })}
</div> </div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Date</label>
<ReactDatePicker
dateFormat="dd/MM/yyyy"
className="px-4 py-6 w-52 text-sm text-center font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none"
selected={startDate}
startDate={startDate}
endDate={endDate}
selectsRange
showMonthDropdown
onChange={([initialDate, finalDate]: [Date, Date]) => {
setStartDate(initialDate ?? moment("01/01/2023").toDate());
if (finalDate) {
// basicly selecting a final day works as if I'm selecting the first
// minute of that day. this way it covers the whole day
setEndDate(moment(finalDate).endOf("day").toDate());
return;
}
setEndDate(null);
}}
/>
</div>
<div>
<table className="rounded-xl h-full bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="p-4 text-left" key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
key={row.id}
>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex flex-wrap gap-2 items-center text-center">
{consolidateResults.highest && (
<IconCard
onClick={() => {}}
Icon={BsPersonFill}
label={`Highest result: ${consolidateResults.highest.user}`}
color="purple"
/>
)}
{consolidateResults.lowest && (
<IconCard
onClick={() => {}}
Icon={BsPersonFill}
label={`Lowest result: ${consolidateResults.lowest.user}`}
color="purple"
/>
)}
</div>
</>
); );
}; };

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" className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
data-tip="Your screen size is too small to perform an assignment"> data-tip="Your screen size is too small to perform an assignment">
<Button <Button
disabled={moment(assignment.startDate).isAfter(moment())} disabled={!assignment.start}
className="h-full w-full !rounded-xl" className="h-full w-full !rounded-xl"
variant="outline"> variant="outline">
Start Start
</Button> </Button>
</div> </div>
<Button <Button
disabled={moment(assignment.startDate).isAfter(moment())} disabled={!assignment.start}
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl" className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
onClick={() => startAssignment(assignment)} onClick={() => startAssignment(assignment)}
variant="outline"> variant="outline">

View File

@@ -48,6 +48,12 @@ import AssignmentView from "./AssignmentView";
import { getUserCorporate } from "@/utils/groups"; import { getUserCorporate } from "@/utils/groups";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import {
futureAssignmentFilter,
pastAssignmentFilter,
archivedAssignmentFilter,
activeAssignmentFilter
} from '@/utils/assignments';
interface Props { interface Props {
user: User; user: User;
@@ -59,13 +65,18 @@ export default function TeacherDashboard({user}: Props) {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>(); const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false); const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>(); const [corporateUserToShow, setCorporateUserToShow] =
useState<CorporateUser>();
const { stats } = useStats(); const { stats } = useStats();
const { users, reload } = useUsers(); const { users, reload } = useUsers();
const { groups } = useGroups({ adminAdmins: user.id }); const { groups } = useGroups({ adminAdmins: user.id });
const { permissions } = usePermissions(user.id); const { permissions } = usePermissions(user.id);
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id}); const {
assignments,
isLoading: isAssignmentsLoading,
reload: reloadAssignments,
} = useAssignments({ assigner: user.id });
useEffect(() => { useEffect(() => {
setShowModal(!!selectedUser && page === ""); setShowModal(!!selectedUser && page === "");
@@ -75,15 +86,23 @@ export default function TeacherDashboard({user}: Props) {
getUserCorporate(user.id).then(setCorporateUserToShow); getUserCorporate(user.id).then(setCorporateUserToShow);
}, [user]); }, [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) => ( const UserDisplay = (displayUser: User) => (
<div <div
onClick={() => setSelectedUser(displayUser)} 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"> 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" /> >
<img
src={displayUser.profilePicture}
alt={displayUser.name}
className="rounded-full w-10 h-10"
/>
<div className="flex flex-col gap-1 items-start"> <div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span> <span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</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 className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
</div> </div>
@@ -128,11 +148,14 @@ export default function TeacherDashboard({user}: Props) {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
</div> </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> </div>
<GroupList user={user} /> <GroupList user={user} />
@@ -150,7 +173,12 @@ export default function TeacherDashboard({user}: Props) {
.filter((f) => !!f.focus); .filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({ const bandScores = formattedStats.map((s) => ({
module: s.module, 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 } = {
@@ -166,12 +194,6 @@ export default function TeacherDashboard({user}: Props) {
}; };
const AssignmentsPage = () => { 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 ( return (
<> <>
<AssignmentView <AssignmentView
@@ -185,7 +207,9 @@ export default function TeacherDashboard({user}: Props) {
/> />
<AssignmentCreator <AssignmentCreator
assignment={selectedAssignment} 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( users={users.filter(
(x) => (x) =>
x.type === "student" && x.type === "student" &&
@@ -194,7 +218,7 @@ export default function TeacherDashboard({user}: Props) {
.filter((g) => g.admin === selectedUser.id) .filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants) .flatMap((g) => g.participants)
.includes(x.id) .includes(x.id)
: groups.flatMap((g) => g.participants).includes(x.id)), : groups.flatMap((g) => g.participants).includes(x.id))
)} )}
assigner={user.id} assigner={user.id}
isCreating={isCreatingAssignment} isCreating={isCreatingAssignment}
@@ -207,35 +231,52 @@ export default function TeacherDashboard({user}: Props) {
<div className="w-full flex justify-between items-center"> <div className="w-full flex justify-between items-center">
<div <div
onClick={() => setPage("")} 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" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
</div> </div>
<div <div
onClick={reloadAssignments} 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> <span>Reload</span>
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} /> <BsArrowRepeat
className={clsx(
"text-xl",
isAssignmentsLoading && "animate-spin"
)}
/>
</div> </div>
</div> </div>
<section className="flex flex-col gap-4"> <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"> <div className="flex flex-wrap gap-2">
{assignments.filter(activeFilter).map((a) => ( {assignments.filter(activeAssignmentFilter).map((a) => (
<AssignmentCard {...a} users={users} onClick={() => setSelectedAssignment(a)} key={a.id} /> <AssignmentCard
{...a}
users={users}
onClick={() => setSelectedAssignment(a)}
key={a.id}
/>
))} ))}
</div> </div>
</section> </section>
<section className="flex flex-col gap-4"> <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 className="flex flex-wrap gap-2">
<div <div
onClick={() => setIsCreatingAssignment(true)} 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" /> <BsPlus className="text-6xl" />
<span className="text-lg">New Assignment</span> <span className="text-lg">New Assignment</span>
</div> </div>
{assignments.filter(futureFilter).map((a) => ( {assignments.filter(futureAssignmentFilter).map((a) => (
<AssignmentCard <AssignmentCard
{...a} {...a}
users={users} users={users}
@@ -249,9 +290,11 @@ export default function TeacherDashboard({user}: Props) {
</div> </div>
</section> </section>
<section className="flex flex-col gap-4"> <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"> <div className="flex flex-wrap gap-2">
{assignments.filter(pastFilter).map((a) => ( {assignments.filter(pastAssignmentFilter).map((a) => (
<AssignmentCard <AssignmentCard
{...a} {...a}
users={users} users={users}
@@ -266,9 +309,11 @@ export default function TeacherDashboard({user}: Props) {
</div> </div>
</section> </section>
<section className="flex flex-col gap-4"> <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"> <div className="flex flex-wrap gap-2">
{assignments.filter(archivedFilter).map((a) => ( {assignments.filter(archivedAssignmentFilter).map((a) => (
<AssignmentCard <AssignmentCard
{...a} {...a}
users={users} users={users}
@@ -290,14 +335,19 @@ export default function TeacherDashboard({user}: Props) {
<> <>
{corporateUserToShow && ( {corporateUserToShow && (
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1"> <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> </div>
)} )}
<section <section
className={clsx( className={clsx(
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center", "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 <IconCard
onClick={() => setPage("students")} onClick={() => setPage("students")}
Icon={BsPersonFill} Icon={BsPersonFill}
@@ -308,16 +358,29 @@ export default function TeacherDashboard({user}: Props) {
<IconCard <IconCard
Icon={BsClipboard2Data} Icon={BsClipboard2Data}
label="Exams Performed" 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" color="purple"
/> />
<IconCard <IconCard
Icon={BsPaperclip} Icon={BsPaperclip}
label="Average Level" label="Average Level"
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)} value={averageLevelCalculator(
stats.filter((s) =>
groups.flatMap((g) => g.participants).includes(s.user)
)
).toFixed(1)}
color="purple" color="purple"
/> />
{checkAccess(user, ["teacher", "developer"], permissions, "viewGroup") && ( {checkAccess(
user,
["teacher", "developer"],
permissions,
"viewGroup"
) && (
<IconCard <IconCard
Icon={BsPeople} Icon={BsPeople}
label="Groups" label="Groups"
@@ -328,11 +391,14 @@ export default function TeacherDashboard({user}: Props) {
)} )}
<div <div
onClick={() => setPage("assignments")} 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" /> <BsEnvelopePaper className="text-6xl text-mti-purple-light" />
<span className="flex flex-col gap-1 items-center text-xl"> <span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">Assignments</span> <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> </span>
</div> </div>
</section> </section>
@@ -354,7 +420,11 @@ export default function TeacherDashboard({user}: Props) {
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {users
.filter(studentFilter) .filter(studentFilter)
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels)) .sort(
(a, b) =>
calculateAverageLevel(b.levels) -
calculateAverageLevel(a.levels)
)
.map((x) => ( .map((x) => (
<UserDisplay key={x.id} {...x} /> <UserDisplay key={x.id} {...x} />
))} ))}
@@ -367,7 +437,8 @@ export default function TeacherDashboard({user}: Props) {
.filter(studentFilter) .filter(studentFilter)
.sort( .sort(
(a, b) => (a, b) =>
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length, Object.keys(groupByExam(getStatsByStudent(b))).length -
Object.keys(groupByExam(getStatsByStudent(a))).length
) )
.map((x) => ( .map((x) => (
<UserDisplay key={x.id} {...x} /> <UserDisplay key={x.id} {...x} />
@@ -391,9 +462,16 @@ export default function TeacherDashboard({user}: Props) {
if (shouldReload) reload(); if (shouldReload) reload();
}} }}
onViewStudents={ 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} user={selectedUser}
/> />
</div> </div>

View File

@@ -1,13 +1,17 @@
import { Assignment } from "@/interfaces/results"; import { AssignmentWithCorporateId } from "@/interfaces/results";
import axios from "axios"; import axios from "axios";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export default function useAssignmentsCorporates({ export default function useAssignmentsCorporates({
corporates, corporates,
startDate,
endDate,
}: { }: {
corporates: string[]; corporates: string[];
startDate: Date | null;
endDate: Date | null;
}) { }) {
const [assignments, setAssignments] = useState<Assignment[]>([]); const [assignments, setAssignments] = useState<AssignmentWithCorporateId[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
@@ -18,9 +22,15 @@ export default function useAssignmentsCorporates({
} }
setIsLoading(true); setIsLoading(true);
const urlSearchParams = new URLSearchParams({
ids: corporates.join(","),
...(startDate ? { startDate: startDate.toISOString() } : {}),
...(endDate ? { endDate: endDate.toISOString() } : {}),
});
axios axios
.get<Assignment[]>( .get<AssignmentWithCorporateId[]>(
`/api/assignments/corporate?ids=${corporates.join(",")}` `/api/assignments/corporate?${urlSearchParams.toString()}`
) )
.then(async (response) => { .then(async (response) => {
setAssignments(response.data); setAssignments(response.data);
@@ -28,7 +38,7 @@ export default function useAssignmentsCorporates({
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
useEffect(getData, [corporates]); useEffect(getData, [corporates, startDate, endDate]);
return { assignments, isLoading, isError, reload: getData }; return { assignments, isLoading, isError, reload: getData };
} }

View File

@@ -0,0 +1,42 @@
import React from "react";
import axios from "axios";
import {toast} from "react-toastify";
import {BsDoorOpen} from "react-icons/bs";
export const useAssignmentRelease = (assignmentId: string, reload?: Function) => {
const [loading, setLoading] = React.useState(false);
const archive = () => {
// archive assignment
setLoading(true);
axios
.post(`/api/assignments/${assignmentId}/release`)
.then((res) => {
toast.success("Assignment released!");
if (reload) reload();
setLoading(false);
})
.catch((err) => {
toast.error("Failed to release the assignment!");
setLoading(false);
});
};
const renderIcon = (downloadClasses: string, loadingClasses: string) => {
if (loading) {
return <span className={`${loadingClasses} loading loading-infinity w-6`} />;
}
return (
<div
className="tooltip flex items-center justify-center w-fit h-fit"
data-tip="Release assignment"
onClick={(e) => {
e.stopPropagation();
archive();
}}>
<BsDoorOpen className={`${downloadClasses} text-2xl cursor-pointer tooltip`} />
</div>
);
};
return renderIcon;
};

View File

@@ -10,19 +10,27 @@ interface ModuleResult {
total: number; total: number;
} }
export interface AssignmentResult {
user: string;
type: "academic" | "general";
stats: Stat[];
}
export interface Assignment { export interface Assignment {
id: string; id: string;
name: string; name: string;
assigner: string; assigner: string;
assignees: string[]; assignees: string[];
results: { results: AssignmentResult[];
user: string;
type: "academic" | "general";
stats: Stat[];
}[];
exams: {id: string; module: Module; assignee: string}[]; exams: {id: string; module: Module; assignee: string}[];
instructorGender?: InstructorGender; instructorGender?: InstructorGender;
startDate: Date; startDate: Date;
endDate: Date; endDate: Date;
archived?: boolean; 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,33 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { app } from "@/firebase";
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;
}
await setDoc(docSnap.ref, { released: 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 });
}

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 });
}

View File

@@ -5,7 +5,6 @@ import { sessionOptions } from "@/lib/session";
import { getAllAssignersByCorporate } from "@/utils/groups.be"; import { getAllAssignersByCorporate } from "@/utils/groups.be";
import { getAssignmentsByAssigners } from "@/utils/assignments.be"; import { getAssignmentsByAssigners } from "@/utils/assignments.be";
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
@@ -20,14 +19,49 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
} }
async function GET(req: NextApiRequest, res: NextApiResponse) { async function GET(req: NextApiRequest, res: NextApiResponse) {
const { ids } = req.query as { ids: string }; const { ids, startDate, endDate } = req.query as {
ids: string;
startDate?: string;
endDate?: string;
};
const startDateParsed = startDate ? new Date(startDate) : undefined;
const endDateParsed = endDate ? new Date(endDate) : undefined;
try { try {
const idsList = ids.split(","); const idsList = ids.split(",");
const assigners = await Promise.all(idsList.map(getAllAssignersByCorporate)); const assigners = await Promise.all(
const assignmentList = [...assigners.flat(), ...idsList]; idsList.map(async (id) => {
const assignments = await getAssignmentsByAssigners(assignmentList); const assigners = await getAllAssignersByCorporate(id);
res.status(200).json(assignments); return {
corporateId: id,
assigners,
};
})
);
const assignments = await Promise.all(assigners.map(async (data) => {
try {
const assigners = [...new Set([...data.assigners, data.corporateId])];
const assignments = await getAssignmentsByAssigners(
assigners,
startDateParsed,
endDateParsed
);
return assignments.map((assignment) => ({
...assignment,
corporateId: data.corporateId,
}));
} catch (err) {
console.error(err);
return [];
}
}));
console.log(assignments);
// const assignments = await getAssignmentsByAssigners(assignmentList, startDateParsed, endDateParsed);
res.status(200).json(assignments.flat());
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }

View File

@@ -12,6 +12,7 @@ import {capitalize, flatten, uniqBy} from "lodash";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import moment from "moment"; import moment from "moment";
import {sendEmail} from "@/email"; import {sendEmail} from "@/email";
import { release } from "os";
const db = getFirestore(app); const db = getFirestore(app);
@@ -138,6 +139,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
results: [], results: [],
exams, exams,
instructorGender, instructorGender,
released: false,
...body, ...body,
}); });

View File

@@ -1,19 +1,58 @@
import { app } from "@/firebase"; import { app } from "@/firebase";
import { Assignment } from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import {collection, getDocs, getFirestore, query, where} from "firebase/firestore"; import {
collection,
getDocs,
getFirestore,
query,
where,
} from "firebase/firestore";
const db = getFirestore(app); const db = getFirestore(app);
export const getAssignmentsByAssigner = async (id: string) => { export const getAssignmentsByAssigner = async (
const {docs} = await getDocs(query(collection(db, "assignments"), where("assigner", "==", id))); id: string,
startDate?: Date,
endDate?: Date
) => {
const { docs } = await getDocs(
query(
collection(db, "assignments"),
...[
where("assigner", "==", id),
...(startDate ? [where("startDate", ">=", startDate.toISOString())] : []),
// firebase doesnt accept compound queries so we have to filter on the server
// ...endDate ? [where("endDate", "<=", endDate)] : [],
]
)
);
if (endDate) {
return docs
.map((x) => ({ ...(x.data() as Assignment), id: x.id }))
.filter((x) => new Date(x.endDate) <= endDate) as Assignment[];
}
return docs.map((x) => ({ ...x.data(), id: x.id })) as Assignment[]; return docs.map((x) => ({ ...x.data(), id: x.id })) as Assignment[];
}; };
export const getAssignmentsByAssignerBetweenDates = async (id: string, startDate: Date, endDate: Date) => { export const getAssignmentsByAssignerBetweenDates = async (
const {docs} = await getDocs(query(collection(db, "assignments"), where("assigner", "==", id), )); id: string,
startDate: Date,
endDate: Date
) => {
const { docs } = await getDocs(
query(collection(db, "assignments"), where("assigner", "==", id))
);
return docs.map((x) => ({ ...x.data(), id: x.id })) as Assignment[]; return docs.map((x) => ({ ...x.data(), id: x.id })) as Assignment[];
}; };
export const getAssignmentsByAssigners = async (ids: string[]) => { export const getAssignmentsByAssigners = async (
return (await Promise.all(ids.map(getAssignmentsByAssigner))).flat(); ids: string[],
startDate?: Date,
endDate?: Date
) => {
return (
await Promise.all(
ids.map((id) => getAssignmentsByAssigner(id, startDate, endDate))
)
).flat();
}; };

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;

View File

@@ -128,6 +128,7 @@ export const groupBySession = (stats: Stat[]) => groupBy(stats, "session");
export const groupByDate = (stats: Stat[]) => groupBy(stats, "date"); export const groupByDate = (stats: Stat[]) => groupBy(stats, "date");
export const groupByExam = (stats: Stat[]) => groupBy(stats, "exam"); export const groupByExam = (stats: Stat[]) => groupBy(stats, "exam");
export const groupByModule = (stats: Stat[]) => groupBy(stats, "module"); export const groupByModule = (stats: Stat[]) => groupBy(stats, "module");
export const groupByUser = (stats: Stat[]) => groupBy(stats, "user");
export const convertToUserSolutions = (stats: Stat[]): UserSolution[] => { export const convertToUserSolutions = (stats: Stat[]): UserSolution[] => {
return stats.map((stat) => ({ return stats.map((stat) => ({