Major updates on Master Statistical

This commit is contained in:
Joao Ramos
2024-08-24 10:53:11 +01:00
parent 44adc142f6
commit cf1b47fbd2
7 changed files with 1397 additions and 878 deletions

View File

@@ -1,5 +1,5 @@
import clsx from "clsx";
import {IconType} from "react-icons";
import { IconType } from "react-icons";
interface Props {
Icon: IconType;
@@ -8,14 +8,23 @@ interface Props {
color: "purple" | "rose" | "red" | "green";
tooltip?: string;
onClick?: () => void;
isSelected?: boolean;
}
export default function IconCard({Icon, label, value, color, tooltip, onClick}: Props) {
const colorClasses: {[key in typeof color]: string} = {
purple: "text-mti-purple-light",
red: "text-mti-red-light",
rose: "text-mti-rose-light",
green: "text-mti-green-light",
export default function IconCard({
Icon,
label,
value,
color,
tooltip,
onClick,
isSelected,
}: Props) {
const colorClasses: { [key in typeof color]: string } = {
purple: "mti-purple-light",
red: "mti-red-light",
rose: "mti-rose-light",
green: "mti-green-light",
};
return (
@@ -24,12 +33,16 @@ export default function IconCard({Icon, label, value, color, tooltip, onClick}:
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",
tooltip && "tooltip tooltip-bottom",
isSelected && `border border-solid border-${colorClasses[color]}`
)}
data-tip={tooltip}>
<Icon className={clsx("text-6xl", colorClasses[color])} />
data-tip={tooltip}
>
<Icon className={clsx("text-6xl", `text-${colorClasses[color]}`)} />
<span className="flex flex-col gap-1 items-center text-xl">
<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>
</div>
);

View File

@@ -2,11 +2,17 @@
import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats";
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 {dateSorter} from "@/utils";
import { dateSorter } from "@/utils";
import moment from "moment";
import {useEffect, useState} from "react";
import { useEffect, useState, useMemo } from "react";
import {
BsArrowLeft,
BsClipboard2Data,
@@ -27,30 +33,38 @@ import {
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {Module} from "@/interfaces";
import {groupByExam} from "@/utils/stats";
import {
averageLevelCalculator,
calculateAverageLevel,
calculateBandScore,
} from "@/utils/score";
import { MODULE_ARRAY } from "@/utils/moduleUtils";
import { Module } from "@/interfaces";
import { groupByExam } from "@/utils/stats";
import IconCard from "./IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList";
import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router";
import { useRouter } from "next/router";
import useCodes from "@/hooks/useCodes";
import useAssignments from "@/hooks/useAssignments";
import {Assignment} from "@/interfaces/results";
import { Assignment } from "@/interfaces/results";
import AssignmentView from "./AssignmentView";
import AssignmentCreator from "./AssignmentCreator";
import clsx from "clsx";
import AssignmentCard from "./AssignmentCard";
import {createColumn, createColumnHelper} from "@tanstack/react-table";
import { createColumn, createColumnHelper } from "@tanstack/react-table";
import List from "@/components/List";
import {getUserCorporate} from "@/utils/groups";
import {getCorporateUser, getUserCompanyName} from "@/resources/user";
import { getUserCorporate } from "@/utils/groups";
import { getCorporateUser, getUserCompanyName } from "@/resources/user";
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 {Menu, MenuButton, MenuItem, MenuItems} from "@headlessui/react";
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import MasterStatistical from "./MasterStatistical";
interface Props {
@@ -58,29 +72,51 @@ interface Props {
}
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;
moment(a.endDate).isAfter(moment()) &&
moment(a.startDate).isBefore(moment()) &&
a.assignees.length > a.results.length;
const pastFilter = (a: Assignment) =>
(moment(a.endDate).isBefore(moment()) ||
a.assignees.length === a.results.length) &&
!a.archived;
const archivedFilter = (a: Assignment) => a.archived;
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
type StudentPerformanceItem = User & {corporate?: CorporateUser; group?: Group};
const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPerformanceItem[]; stats: Stat[]; users: User[]; groups: Group[]}) => {
type StudentPerformanceItem = User & {
corporate?: CorporateUser;
group?: Group;
};
const StudentPerformanceList = ({
items,
stats,
users,
groups,
}: {
items: StudentPerformanceItem[];
stats: Stat[];
users: User[];
groups: Group[];
}) => {
const [isShowingAmount, setIsShowingAmount] = useState(false);
const [availableCorporates] = useState(
uniqBy(
items.map((x) => x.corporate),
"id",
),
"id"
)
);
const [availableGroups] = useState(
uniqBy(
items.map((x) => x.group),
"id",
),
"id"
)
);
const [selectedCorporate, setSelectedCorporate] = useState<CorporateUser | null | undefined>(null);
const [selectedGroup, setSelectedGroup] = useState<Group | null | undefined>(null);
const [selectedCorporate, setSelectedCorporate] = useState<
CorporateUser | null | undefined
>(null);
const [selectedGroup, setSelectedGroup] = useState<Group | null | undefined>(
null
);
const columnHelper = createColumnHelper<StudentPerformanceItem>();
@@ -103,7 +139,10 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
}),
columnHelper.accessor("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", {
header: "Reading",
@@ -111,15 +150,30 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
!isShowingAmount
? calculateBandScore(
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),
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),
"level",
info.row.original.focus || "academic",
info.row.original.focus || "academic"
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`,
: `${
Object.keys(
groupByExam(
stats.filter(
(x) =>
x.module === "reading" && x.user === info.row.original.id
)
)
).length
} exams`,
}),
columnHelper.accessor("levels.listening", {
header: "Listening",
@@ -127,15 +181,31 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
!isShowingAmount
? calculateBandScore(
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),
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),
"level",
info.row.original.focus || "academic",
info.row.original.focus || "academic"
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`,
: `${
Object.keys(
groupByExam(
stats.filter(
(x) =>
x.module === "listening" &&
x.user === info.row.original.id
)
)
).length
} exams`,
}),
columnHelper.accessor("levels.writing", {
header: "Writing",
@@ -143,15 +213,30 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
!isShowingAmount
? calculateBandScore(
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),
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),
"level",
info.row.original.focus || "academic",
info.row.original.focus || "academic"
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`,
: `${
Object.keys(
groupByExam(
stats.filter(
(x) =>
x.module === "writing" && x.user === info.row.original.id
)
)
).length
} exams`,
}),
columnHelper.accessor("levels.speaking", {
header: "Speaking",
@@ -159,15 +244,30 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
!isShowingAmount
? calculateBandScore(
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),
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),
"level",
info.row.original.focus || "academic",
info.row.original.focus || "academic"
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`,
: `${
Object.keys(
groupByExam(
stats.filter(
(x) =>
x.module === "speaking" && x.user === info.row.original.id
)
)
).length
} exams`,
}),
columnHelper.accessor("levels.level", {
header: "Level",
@@ -175,15 +275,28 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
!isShowingAmount
? calculateBandScore(
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),
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),
"level",
info.row.original.focus || "academic",
info.row.original.focus || "academic"
) || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`,
: `${
Object.keys(
groupByExam(
stats.filter(
(x) =>
x.module === "level" && x.user === info.row.original.id
)
)
).length
} exams`,
}),
columnHelper.accessor("levels", {
id: "overall_level",
@@ -192,16 +305,24 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
!isShowingAmount
? averageLevelCalculator(
users,
stats.filter((x) => x.user === info.row.original.id),
stats.filter((x) => x.user === info.row.original.id)
).toFixed(1)
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`,
: `${
Object.keys(
groupByExam(
stats.filter((x) => x.user === info.row.original.id)
)
).length
} exams`,
}),
];
const filterUsers = (data: StudentPerformanceItem[]) => {
console.log(data, selectedCorporate);
const filterByCorporate = (item: StudentPerformanceItem) => item.corporate?.id === selectedCorporate?.id;
const filterByGroup = (item: StudentPerformanceItem) => item.group?.id === selectedGroup?.id;
const filterByCorporate = (item: StudentPerformanceItem) =>
item.corporate?.id === selectedCorporate?.id;
const filterByGroup = (item: StudentPerformanceItem) =>
item.group?.id === selectedGroup?.id;
const filters: ((item: StudentPerformanceItem) => boolean)[] = [];
if (selectedCorporate !== null) filters.push(filterByCorporate);
@@ -228,7 +349,10 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
<Select
options={availableCorporates.map((x) => ({
value: x?.id || "N/A",
label: x?.corporateInformation?.companyInformation?.name || x?.name || "N/A",
label:
x?.corporateInformation?.companyInformation?.name ||
x?.name ||
"N/A",
}))}
isClearable
value={
@@ -237,7 +361,8 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
: {
value: selectedCorporate?.id || "N/A",
label:
selectedCorporate?.corporateInformation?.companyInformation?.name ||
selectedCorporate?.corporateInformation
?.companyInformation?.name ||
selectedCorporate?.name ||
"N/A",
}
@@ -247,7 +372,11 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
!value
? setSelectedCorporate(null)
: 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
)
)
}
/>
@@ -269,7 +398,11 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
onChange={(value) =>
!value
? 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>
@@ -282,13 +415,13 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
(a, b) =>
averageLevelCalculator(
users,
stats.filter((x) => x.user === b.id),
stats.filter((x) => x.user === b.id)
) -
averageLevelCalculator(
users,
stats.filter((x) => x.user === a.id),
),
),
stats.filter((x) => x.user === a.id)
)
)
)}
columns={columns}
/>
@@ -296,24 +429,36 @@ const StudentPerformanceList = ({items, stats, users, groups}: {items: StudentPe
);
};
export default function MasterCorporateDashboard({user}: Props) {
export default function MasterCorporateDashboard({ user }: Props) {
const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
const [corporateAssignments, setCorporateAssignments] = useState<(Assignment & {corporate?: CorporateUser})[]>([]);
const [corporateAssignments, setCorporateAssignments] = useState<
(Assignment & { corporate?: CorporateUser })[]
>([]);
const {stats} = useStats();
const {users, reload} = useUsers();
const {codes} = useCodes(user.id);
const {groups} = useGroups({admin: user.id, userType: user.type});
const { stats } = useStats();
const { users, reload } = useUsers();
const { codes } = useCodes(user.id);
const { groups } = useGroups({ admin: user.id, userType: user.type });
const masterCorporateUserGroups = [...new Set(groups.filter((u) => u.admin === user.id).flatMap((g) => g.participants))];
const masterCorporateUserGroups = [
...new Set(
groups.filter((u) => u.admin === user.id).flatMap((g) => g.participants)
),
];
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 router = useRouter();
@@ -327,21 +472,33 @@ export default function MasterCorporateDashboard({user}: Props) {
assignments.filter(activeFilter).map((a) => ({
...a,
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,
})),
}))
);
}, [assignments, groups, users]);
const studentFilter = (user: User) => user.type === "student" && corporateUserGroups.includes(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 studentFilter = (user: User) =>
user.type === "student" && corporateUserGroups.includes(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) => (
<div
onClick={() => setSelectedUser(displayUser)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"
>
<img
src={displayUser.profilePicture}
alt={displayUser.name}
className="rounded-full w-10 h-10"
/>
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
@@ -351,7 +508,10 @@ export default function MasterCorporateDashboard({user}: Props) {
const StudentsList = () => {
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 (
<UserList
@@ -361,7 +521,8 @@ export default function MasterCorporateDashboard({user}: Props) {
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
@@ -374,7 +535,10 @@ export default function MasterCorporateDashboard({user}: Props) {
const TeachersList = () => {
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 (
<UserList
@@ -384,7 +548,8 @@ export default function MasterCorporateDashboard({user}: Props) {
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
@@ -396,7 +561,10 @@ export default function MasterCorporateDashboard({user}: Props) {
};
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 = () => {
return (
@@ -407,7 +575,8 @@ export default function MasterCorporateDashboard({user}: Props) {
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
@@ -424,7 +593,8 @@ export default function MasterCorporateDashboard({user}: Props) {
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
@@ -451,7 +621,11 @@ export default function MasterCorporateDashboard({user}: Props) {
const StudentPerformancePage = () => {
const students = users
.filter((x) => x.type === "student" && groups.flatMap((g) => g.participants).includes(x.id))
.filter(
(x) =>
x.type === "student" &&
groups.flatMap((g) => g.participants).includes(x.id)
)
.map((u) => ({
...u,
group: groups.find((x) => x.participants.includes(u.id)),
@@ -463,18 +637,30 @@ export default function MasterCorporateDashboard({user}: Props) {
<div className="w-full flex justify-between items-center">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<div
onClick={reloadAssignments}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<span>Reload</span>
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
<BsArrowRepeat
className={clsx(
"text-xl",
isAssignmentsLoading && "animate-spin"
)}
/>
</div>
</div>
<StudentPerformanceList items={students} stats={stats} users={users} groups={groups} />
<StudentPerformanceList
items={students}
stats={stats}
users={users}
groups={groups}
/>
</>
);
};
@@ -493,7 +679,9 @@ export default function MasterCorporateDashboard({user}: Props) {
/>
<AssignmentCreator
assignment={selectedAssignment}
groups={groups.filter((x) => x.admin === user.id || x.participants.includes(user.id))}
groups={groups.filter(
(x) => x.admin === user.id || x.participants.includes(user.id)
)}
users={users.filter(
(x) =>
x.type === "student" &&
@@ -502,7 +690,7 @@ export default function MasterCorporateDashboard({user}: Props) {
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id)),
: groups.flatMap((g) => g.participants).includes(x.id))
)}
assigner={user.id}
isCreating={isCreatingAssignment}
@@ -515,49 +703,86 @@ export default function MasterCorporateDashboard({user}: Props) {
<div className="w-full flex justify-between items-center">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<div
onClick={reloadAssignments}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<span>Reload</span>
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
<BsArrowRepeat
className={clsx(
"text-xl",
isAssignmentsLoading && "animate-spin"
)}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-lg font-bold">Active Assignments Status</span>
<div className="flex items-center gap-4">
<span>
<b>Total:</b> {assignments.filter(activeFilter).reduce((acc, curr) => acc + curr.results.length, 0)}/
{assignments.filter(activeFilter).reduce((acc, curr) => curr.exams.length + acc, 0)}
<b>Total:</b>{" "}
{assignments
.filter(activeFilter)
.reduce((acc, curr) => acc + curr.results.length, 0)}
/
{assignments
.filter(activeFilter)
.reduce((acc, curr) => curr.exams.length + acc, 0)}
</span>
{Object.keys(groupBy(corporateAssignments, (x) => x.corporate?.id)).map((x) => (
{Object.keys(
groupBy(corporateAssignments, (x) => x.corporate?.id)
).map((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>
{groupBy(corporateAssignments, (x) => x.corporate?.id)[x].reduce((acc, curr) => curr.results.length + acc, 0)}/
{groupBy(corporateAssignments, (x) => x.corporate?.id)[x].reduce((acc, curr) => curr.exams.length + acc, 0)}
{groupBy(corporateAssignments, (x) => x.corporate?.id)[
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>
</div>
))}
</div>
</div>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeFilter).length})</h2>
<h2 className="text-2xl font-semibold">
Active Assignments ({assignments.filter(activeFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(activeFilter).map((a) => (
<AssignmentCard {...a} users={users} onClick={() => setSelectedAssignment(a)} key={a.id} />
<AssignmentCard
{...a}
users={users}
onClick={() => setSelectedAssignment(a)}
key={a.id}
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureFilter).length})</h2>
<h2 className="text-2xl font-semibold">
Planned Assignments ({assignments.filter(futureFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
<div
onClick={() => setIsCreatingAssignment(true)}
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300"
>
<BsPlus className="text-6xl" />
<span className="text-lg">New Assignment</span>
</div>
@@ -575,7 +800,9 @@ export default function MasterCorporateDashboard({user}: Props) {
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
<h2 className="text-2xl font-semibold">
Past Assignments ({assignments.filter(pastFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(pastFilter).map((a) => (
<AssignmentCard
@@ -592,7 +819,9 @@ export default function MasterCorporateDashboard({user}: Props) {
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Archived Assignments ({assignments.filter(archivedFilter).length})</h2>
<h2 className="text-2xl font-semibold">
Archived Assignments ({assignments.filter(archivedFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(archivedFilter).map((a) => (
<AssignmentCard
@@ -612,13 +841,24 @@ 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 = () => {
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
>
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
@@ -626,11 +866,7 @@ export default function MasterCorporateDashboard({user}: Props) {
</div>
<MasterStatistical
users={users}
corporateUsers={masterCorporateUserGroups.reduce((accm: CorporateUser[], id) => {
const user = users.find((u) => u.id === id) as CorporateUser;
if (user) return [...accm, user];
return accm;
}, [])}
corporateUsers={masterCorporateUsers}
/>
</>
);
@@ -656,7 +892,11 @@ export default function MasterCorporateDashboard({user}: Props) {
<IconCard
Icon={BsClipboard2Data}
label="Exams Performed"
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
value={
stats.filter((s) =>
groups.flatMap((g) => g.participants).includes(s.user)
).length
}
color="purple"
/>
<IconCard
@@ -664,21 +904,35 @@ export default function MasterCorporateDashboard({user}: Props) {
label="Average Level"
value={averageLevelCalculator(
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)}
color="purple"
/>
<IconCard onClick={() => setPage("groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
<IconCard
onClick={() => setPage("groups")}
Icon={BsPeople}
label="Groups"
value={groups.length}
color="purple"
/>
<IconCard
Icon={BsPersonCheck}
label="User Balance"
value={`${codes.length}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
value={`${codes.length}/${
user.corporateInformation?.companyInformation?.userAmount || 0
}`}
color="purple"
/>
<IconCard
Icon={BsClock}
label="Expiration Date"
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
value={
user.subscriptionExpirationDate
? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy")
: "Unlimited"
}
color="rose"
/>
<IconCard
@@ -705,12 +959,15 @@ export default function MasterCorporateDashboard({user}: Props) {
<button
disabled={isAssignmentsLoading}
onClick={() => setPage("assignments")}
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
className="bg-white col-span-2 rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300"
>
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
<span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">Assignments</span>
<span className="font-semibold text-mti-purple-light">
{isAssignmentsLoading ? "Loading..." : assignments.filter((a) => !a.archived).length}
{isAssignmentsLoading
? "Loading..."
: assignments.filter((a) => !a.archived).length}
</span>
</span>
</button>
@@ -744,7 +1001,11 @@ export default function MasterCorporateDashboard({user}: Props) {
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
.sort(
(a, b) =>
calculateAverageLevel(b.levels) -
calculateAverageLevel(a.levels)
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
@@ -757,7 +1018,8 @@ export default function MasterCorporateDashboard({user}: Props) {
.filter(studentFilter)
.sort(
(a, b) =>
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
Object.keys(groupByExam(getStatsByStudent(b))).length -
Object.keys(groupByExam(getStatsByStudent(a))).length
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
@@ -781,7 +1043,8 @@ export default function MasterCorporateDashboard({user}: Props) {
if (shouldReload) reload();
}}
onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "teacher"
selectedUser.type === "corporate" ||
selectedUser.type === "teacher"
? () => {
appendUserFilters({
id: "view-students",
@@ -791,7 +1054,11 @@ export default function MasterCorporateDashboard({user}: Props) {
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.filter(
(g) =>
g.admin === selectedUser.id ||
g.participants.includes(selectedUser.id)
)
.flatMap((g) => g.participants)
.includes(x.id),
});
@@ -801,7 +1068,8 @@ export default function MasterCorporateDashboard({user}: Props) {
: undefined
}
onViewTeachers={
selectedUser.type === "corporate" || selectedUser.type === "student"
selectedUser.type === "corporate" ||
selectedUser.type === "student"
? () => {
appendUserFilters({
id: "view-teachers",
@@ -811,7 +1079,11 @@ export default function MasterCorporateDashboard({user}: Props) {
id: "belongs-to-admin",
filter: (x: User) =>
groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.filter(
(g) =>
g.admin === selectedUser.id ||
g.participants.includes(selectedUser.id)
)
.flatMap((g) => g.participants)
.includes(x.id),
});

View File

@@ -5,11 +5,20 @@ import IconCard from "./IconCard";
import useAssignmentsCorporates from "@/hooks/useAssignmentCorporates";
import ReactDatePicker from "react-datepicker";
import moment from "moment";
import { groupBySession } from "@/utils/stats";
import { Assignment, AssignmentResult } from "@/interfaces/results";
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 {
corporateUsers: CorporateUser[];
corporateUsers: User[];
users: User[];
}
@@ -20,11 +29,29 @@ interface TableData {
submitted: boolean;
date: moment.Moment;
assignment: string;
corporateId: string;
}
interface UserCount {
userCount: number;
maxUserCount: number;
}
const MasterStatistical = (props: Props) => {
const { users, corporateUsers } = props;
const corporates = React.useMemo(() => corporateUsers.map((x) => x.id), [corporateUsers]);
const corporateRelevantUsers = React.useMemo(
() => corporateUsers.filter((x) => x.type !== "student") as CorporateUser[],
[corporateUsers]
);
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()
);
@@ -33,51 +60,189 @@ const MasterStatistical = (props: Props) => {
);
const { assignments } = useAssignmentsCorporates({
corporates,
// corporates: [...corporates, "tYU0HTiJdjMsS8SB7XJsUdMMP892"],
corporates: selectedCorporates,
startDate,
endDate,
});
const x = assignments.reduce((accmA: TableData[], a: Assignment) => {
const userResults = a.results.reduce((accmB: TableData[], r: AssignmentResult) => {
const userStats = groupBySession(r.stats);
const data = Object.keys(userStats).map((key) => ({
user: users.find((u) => u.id === r.user)?.name || "",
correct: userStats[key].reduce((n, e) => n + e.score.correct, 0),
corporate: users.find((u) => u.id === a.assigner)?.name || "",
submitted: false,
date: moment.max(userStats[key].map((e) => moment(e.date))),
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,
}));
return [...accmB, ...data];
}, []);
};
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 (
<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;
return (
<>
<div className="flex flex-wrap gap-2 items-center text-center">
<IconCard
Icon={BsBank}
label="Consolidate"
value={0}
value={getConsolidateScoreStr(consolidateScore)}
color="purple"
onClick={() => console.log("clicked")}
onClick={() => {
if (areAllSelected) {
setSelectedCorporates([]);
return;
}
setSelectedCorporates(corporates);
}}
isSelected={areAllSelected}
/>
{corporateUsers.map((group) => (
{corporateRelevantUsers.map((group) => {
const isSelected = selectedCorporates.includes(group.id);
return (
<IconCard
key={group.id}
Icon={BsBank}
label={group.corporateInformation?.companyInformation?.name}
value={0}
value={getConsolidateScoreStr(corporateScores[group.id])}
color="purple"
onClick={() => console.log("clicked", group)}
onClick={() => {
if (isSelected) {
setSelectedCorporates(
selectedCorporates.filter((x) => x !== group.id)
);
return;
}
setSelectedCorporates([...selectedCorporates, group.id]);
}}
isSelected={isSelected}
/>
))}
);
})}
</div>
<div className="flex gap-3 w-full">
<ReactDatePicker
dateFormat="dd/MM/yyyy"
className="px-4 py-6 w-full 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"
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}
@@ -94,7 +259,42 @@ const MasterStatistical = (props: Props) => {
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>
<IconCard
onClick={() => console.log("clicked")}
Icon={BsPersonFill}
@@ -102,6 +302,7 @@ const MasterStatistical = (props: Props) => {
color="purple"
/>
</div>
</>
);
};

View File

@@ -1,6 +1,5 @@
import { Assignment } from "@/interfaces/results";
import { AssignmentWithCorporateId } from "@/interfaces/results";
import axios from "axios";
import moment from "moment";
import { useEffect, useState } from "react";
export default function useAssignmentsCorporates({
@@ -12,7 +11,7 @@ export default function useAssignmentsCorporates({
startDate: Date | null;
endDate: Date | null;
}) {
const [assignments, setAssignments] = useState<Assignment[]>([]);
const [assignments, setAssignments] = useState<AssignmentWithCorporateId[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
@@ -30,7 +29,7 @@ export default function useAssignmentsCorporates({
});
axios
.get<Assignment[]>(
.get<AssignmentWithCorporateId[]>(
`/api/assignments/corporate?${urlSearchParams.toString()}`
)
.then(async (response) => {

View File

@@ -29,3 +29,5 @@ export interface Assignment {
archived?: boolean;
released?: boolean;
}
export type AssignmentWithCorporateId = Assignment & { corporateId: string };

View File

@@ -5,7 +5,6 @@ import { sessionOptions } from "@/lib/session";
import { getAllAssignersByCorporate } from "@/utils/groups.be";
import { getAssignmentsByAssigners } from "@/utils/assignments.be";
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
@@ -20,17 +19,49 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
}
async function GET(req: NextApiRequest, res: NextApiResponse) {
const { ids, startDate, endDate } = req.query as { ids: string, startDate?: string, endDate?: 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 {
const idsList = ids.split(",");
const assigners = await Promise.all(idsList.map(getAllAssignersByCorporate));
const assignmentList = [...assigners.flat(), ...idsList];
const assignments = await getAssignmentsByAssigners(assignmentList, startDateParsed, endDateParsed);
res.status(200).json(assignments);
const assigners = await Promise.all(
idsList.map(async (id) => {
const assigners = await getAllAssignersByCorporate(id);
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) {
res.status(500).json({ error: err.message });
}

View File

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