Merge branch 'main' into develop

This commit is contained in:
Tiago Ribeiro
2024-09-09 17:52:04 +01:00
11 changed files with 494 additions and 402 deletions

View File

@@ -1,51 +1,77 @@
import {Column, flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table"; import {Column, flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table";
import {useMemo, useState} from "react";
import Button from "./Low/Button";
const SIZE = 25;
export default function List<T>({data, columns}: {data: T[]; columns: any[]}) { export default function List<T>({data, columns}: {data: T[]; columns: any[]}) {
const [page, setPage] = useState(0);
const items = useMemo(() => data.slice(page * SIZE, (page + 1) * SIZE > data.length ? data.length : (page + 1) * SIZE), [data, page]);
const table = useReactTable({ const table = useReactTable({
data, data: items,
columns: columns, columns: columns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
}); });
return ( return (
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full"> <div className="w-full h-full flex flex-col gap-2">
<thead> <div className="w-full flex gap-2 justify-between">
{table.getHeaderGroups().map((headerGroup) => ( <Button className="w-full max-w-[200px]" disabled={page === 0} onClick={() => setPage((prev) => prev - 1)}>
<tr key={headerGroup.id}> Previous Page
{headerGroup.headers.map((header) => ( </Button>
<th key={header.id} colSpan={header.colSpan}> <div className="flex items-center gap-4 w-fit">
{header.isPlaceholder ? null : ( <span className="opacity-80">
<> {page * SIZE + 1} - {(page + 1) * SIZE > data.length ? data.length : (page + 1) * SIZE} / {data.length}
<div </span>
{...{ <Button className="w-[200px]" disabled={(page + 1) * SIZE >= data.length} onClick={() => setPage((prev) => prev + 1)}>
className: header.column.getCanSort() ? "cursor-pointer select-none py-4 text-left first:pl-4" : "", Next Page
onClick: header.column.getToggleSortingHandler(), </Button>
}}> </div>
{flexRender(header.column.columnDef.header, header.getContext())} </div>
{{
asc: " 🔼", <table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
desc: " 🔽", <thead>
}[header.column.getIsSorted() as string] ?? null} {table.getHeaderGroups().map((headerGroup) => (
</div> <tr key={headerGroup.id}>
</> {headerGroup.headers.map((header) => (
)} <th key={header.id} colSpan={header.colSpan}>
</th> {header.isPlaceholder ? null : (
))} <>
</tr> <div
))} {...{
</thead> className: header.column.getCanSort()
<tbody className="px-2"> ? "cursor-pointer select-none py-4 text-left first:pl-4"
{table.getRowModel().rows.map((row) => ( : "",
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}> onClick: header.column.getToggleSortingHandler(),
{row.getVisibleCells().map((cell) => ( }}>
<td className="px-4 py-2" key={cell.id}> {flexRender(header.column.columnDef.header, header.getContext())}
{flexRender(cell.column.columnDef.cell, cell.getContext())} {{
</td> asc: " 🔼",
))} desc: " 🔽",
</tr> }[header.column.getIsSorted() as string] ?? null}
))} </div>
</tbody> </>
</table> )}
</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>
); );
} }

View File

@@ -63,7 +63,7 @@ export default function AdminDashboard({user}: Props) {
const {users: students, total: totalStudents, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(studentHash); const {users: students, total: totalStudents, reload: reloadStudents, isLoading: isStudentsLoading} = useUsers(studentHash);
const {users: teachers, total: totalTeachers, reload: reloadTeachers, isLoading: isTeachersLoading} = useUsers(teacherHash); const {users: teachers, total: totalTeachers, reload: reloadTeachers, isLoading: isTeachersLoading} = useUsers(teacherHash);
const {users: corporates, total: totalCorporate, reload: reloadCorporates, isLoading: isCorporatesLoading} = useUsers(corporateHash); const {users: corporates, total: totalCorporate, reload: reloadCorporates, isLoading: isCorporatesLoading} = useUsers(corporateHash);
const {users: agents, total: totalAgents, reload: reloadAgents, isLoading: isAgentsLoading} = useUsers(corporateHash); const {users: agents, total: totalAgents, reload: reloadAgents, isLoading: isAgentsLoading} = useUsers(agentsHash);
const {groups} = useGroups({}); const {groups} = useGroups({});
const {pending, done} = usePaymentStatusUsers(); const {pending, done} = usePaymentStatusUsers();

View File

@@ -21,6 +21,7 @@ import Checkbox from "@/components/Low/Checkbox";
import {InstructorGender, Variant} from "@/interfaces/exam"; import {InstructorGender, Variant} from "@/interfaces/exam";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import useExams from "@/hooks/useExams"; import useExams from "@/hooks/useExams";
import {useListSearch} from "@/hooks/useListSearch";
interface Props { interface Props {
isCreating: boolean; isCreating: boolean;
@@ -31,7 +32,12 @@ interface Props {
cancelCreation: () => void; cancelCreation: () => void;
} }
const SIZE = 12;
export default function AssignmentCreator({isCreating, assignment, user, groups, users, cancelCreation}: Props) { export default function AssignmentCreator({isCreating, assignment, user, groups, users, cancelCreation}: Props) {
const [studentsPage, setStudentsPage] = useState(0);
const [teachersPage, setTeachersPage] = useState(0);
const [selectedModules, setSelectedModules] = useState<Module[]>(assignment?.exams.map((e) => e.module) || []); const [selectedModules, setSelectedModules] = useState<Module[]>(assignment?.exams.map((e) => e.module) || []);
const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []); const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []);
const [teachers, setTeachers] = useState<string[]>(!!assignment ? assignment.teachers || [] : [...(user.type === "teacher" ? [user.id] : [])]); const [teachers, setTeachers] = useState<string[]>(!!assignment ? assignment.teachers || [] : [...(user.type === "teacher" ? [user.id] : [])]);
@@ -69,6 +75,29 @@ export default function AssignmentCreator({isCreating, assignment, user, groups,
const userStudents = useMemo(() => users.filter((x) => x.type === "student"), [users]); const userStudents = useMemo(() => users.filter((x) => x.type === "student"), [users]);
const userTeachers = useMemo(() => users.filter((x) => x.type === "teacher"), [users]); const userTeachers = useMemo(() => users.filter((x) => x.type === "teacher"), [users]);
const {rows: filteredStudentsRows, renderSearch: renderStudentSearch, text: studentText} = useListSearch([["name"], ["email"]], userStudents);
const {rows: filteredTeachersRows, renderSearch: renderTeacherSearch, text: teacherText} = useListSearch([["name"], ["email"]], userTeachers);
useEffect(() => setStudentsPage(0), [studentText]);
const studentRows = useMemo(
() =>
filteredStudentsRows.slice(
studentsPage * SIZE,
(studentsPage + 1) * SIZE > filteredStudentsRows.length ? filteredStudentsRows.length : (studentsPage + 1) * SIZE,
),
[filteredStudentsRows, studentsPage],
);
useEffect(() => setTeachersPage(0), [teacherText]);
const teacherRows = useMemo(
() =>
filteredTeachersRows.slice(
teachersPage * SIZE,
(teachersPage + 1) * SIZE > filteredTeachersRows.length ? filteredTeachersRows.length : (teachersPage + 1) * SIZE,
),
[filteredTeachersRows, teachersPage],
);
useEffect(() => { useEffect(() => {
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module))); setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module)));
}, [selectedModules]); }, [selectedModules]);
@@ -347,9 +376,9 @@ export default function AssignmentCreator({isCreating, assignment, user, groups,
</div> </div>
)} )}
<section className="w-full flex flex-col gap-3"> <section className="w-full flex flex-col gap-4">
<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="grid grid-cols-5 gap-4">
{groups.map((g) => ( {groups.map((g) => (
<button <button
key={g.id} key={g.id}
@@ -371,8 +400,11 @@ export default function AssignmentCreator({isCreating, assignment, user, groups,
</button> </button>
))} ))}
</div> </div>
{renderStudentSearch()}
<div className="flex flex-wrap -md:justify-center gap-4"> <div className="flex flex-wrap -md:justify-center gap-4">
{userStudents.map((user) => ( {studentRows.map((user) => (
<div <div
onClick={() => toggleAssignee(user)} onClick={() => toggleAssignee(user)}
className={clsx( className={clsx(
@@ -402,12 +434,32 @@ export default function AssignmentCreator({isCreating, assignment, user, groups,
</div> </div>
))} ))}
</div> </div>
<div className="w-full flex gap-2 justify-between items-center">
<div className="flex items-center gap-4 w-fit">
<Button className="w-[200px] h-fit" disabled={studentsPage === 0} onClick={() => setStudentsPage((prev) => prev - 1)}>
Previous Page
</Button>
</div>
<div className="flex items-center gap-4 w-fit">
<span className="opacity-80">
{studentsPage * SIZE + 1} -{" "}
{(studentsPage + 1) * SIZE > filteredStudentsRows.length ? filteredStudentsRows.length : (studentsPage + 1) * SIZE} /{" "}
{filteredStudentsRows.length}
</span>
<Button
className="w-[200px]"
disabled={(studentsPage + 1) * SIZE >= filteredStudentsRows.length}
onClick={() => setStudentsPage((prev) => prev + 1)}>
Next Page
</Button>
</div>
</div>
</section> </section>
{user.type !== "teacher" && ( {user.type !== "teacher" && (
<section className="w-full flex flex-col gap-3"> <section className="w-full flex flex-col gap-3">
<span className="font-semibold">Teachers ({teachers.length} selected)</span> <span className="font-semibold">Teachers ({teachers.length} selected)</span>
<div className="flex gap-4 overflow-x-scroll scrollbar-hide"> <div className="grid grid-cols-5 gap-4">
{groups.map((g) => ( {groups.map((g) => (
<button <button
key={g.id} key={g.id}
@@ -429,8 +481,11 @@ export default function AssignmentCreator({isCreating, assignment, user, groups,
</button> </button>
))} ))}
</div> </div>
{renderTeacherSearch()}
<div className="flex flex-wrap -md:justify-center gap-4"> <div className="flex flex-wrap -md:justify-center gap-4">
{userTeachers.map((user) => ( {teacherRows.map((user) => (
<div <div
onClick={() => toggleTeacher(user)} onClick={() => toggleTeacher(user)}
className={clsx( className={clsx(
@@ -453,6 +508,29 @@ export default function AssignmentCreator({isCreating, assignment, user, groups,
</div> </div>
))} ))}
</div> </div>
<div className="w-full flex gap-2 justify-between items-center">
<div className="flex items-center gap-4 w-fit">
<Button className="w-[200px] h-fit" disabled={teachersPage === 0} onClick={() => setTeachersPage((prev) => prev - 1)}>
Previous Page
</Button>
</div>
<div className="flex items-center gap-4 w-fit">
<span className="opacity-80">
{teachersPage * SIZE + 1} -{" "}
{(teachersPage + 1) * SIZE > filteredTeachersRows.length
? filteredTeachersRows.length
: (teachersPage + 1) * SIZE}{" "}
/ {filteredTeachersRows.length}
</span>
<Button
className="w-[200px]"
disabled={(teachersPage + 1) * SIZE >= filteredTeachersRows.length}
onClick={() => setTeachersPage((prev) => prev + 1)}>
Next Page
</Button>
</div>
</div>
</section> </section>
)} )}

View File

@@ -1,5 +1,5 @@
import React from "react"; import React, {useEffect, useMemo, useState} from "react";
import {CorporateUser, User} from "@/interfaces/user"; import {CorporateUser, StudentUser, User} from "@/interfaces/user";
import {BsFileExcel, BsBank, BsPersonFill} from "react-icons/bs"; import {BsFileExcel, BsBank, BsPersonFill} from "react-icons/bs";
import IconCard from "../IconCard"; import IconCard from "../IconCard";
@@ -14,6 +14,7 @@ import {useListSearch} from "@/hooks/useListSearch";
import axios from "axios"; import axios from "axios";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import {getUserName} from "@/utils/users";
interface GroupedCorporateUsers { interface GroupedCorporateUsers {
// list of user Ids // list of user Ids
@@ -27,7 +28,7 @@ interface Props {
} }
interface TableData { interface TableData {
user: string; user: User | undefined;
email: string; email: string;
correct: number; correct: number;
corporate: string; corporate: string;
@@ -42,10 +43,13 @@ interface UserCount {
maxUserCount: number; maxUserCount: number;
} }
const searchFilters = [["email"], ["user"], ["userId"]]; const searchFilters = [["email"], ["user"], ["userId"], ["exams"], ["assignment"]];
const SIZE = 16;
const MasterStatistical = (props: Props) => { const MasterStatistical = (props: Props) => {
const {users, corporateUsers, displaySelection = true} = props; const {users, corporateUsers, displaySelection = true} = props;
const [page, setPage] = useState(0);
// const corporateRelevantUsers = React.useMemo( // const corporateRelevantUsers = React.useMemo(
// () => corporateUsers.filter((x) => x.type !== "student") as CorporateUser[], // () => corporateUsers.filter((x) => x.type !== "student") as CorporateUser[],
@@ -58,8 +62,7 @@ const MasterStatistical = (props: Props) => {
const [startDate, setStartDate] = React.useState<Date | null>(moment("01/01/2023").toDate()); const [startDate, setStartDate] = React.useState<Date | null>(moment("01/01/2023").toDate());
const [endDate, setEndDate] = React.useState<Date | null>(moment().endOf("year").toDate()); const [endDate, setEndDate] = React.useState<Date | null>(moment().endOf("year").toDate());
const {assignments} = useAssignmentsCorporates({ const {assignments, isLoading} = useAssignmentsCorporates({
// corporates: [...corporates, "tYU0HTiJdjMsS8SB7XJsUdMMP892"],
corporates: selectedCorporates, corporates: selectedCorporates,
startDate, startDate,
endDate, endDate,
@@ -73,12 +76,15 @@ const MasterStatistical = (props: Props) => {
const userResults = a.assignees.map((assignee) => { const userResults = a.assignees.map((assignee) => {
const userStats = a.results.find((r) => r.user === assignee)?.stats || []; const userStats = a.results.find((r) => r.user === assignee)?.stats || [];
const userData = users.find((u) => u.id === assignee); const userData = users.find((u) => u.id === assignee);
const corporate = users.find((u) => u.id === a.assigner)?.name || ""; if (!!userData) console.log(assignee, userData.name);
const corporate = getUserName(users.find((u) => u.id === a.assigner));
const commonData = { const commonData = {
user: userData?.name || "", user: userData,
email: userData?.email || "", email: userData?.email || "N/A",
userId: assignee, userId: assignee,
corporateId: a.corporateId, corporateId: a.corporateId,
exams: a.exams.map((x) => x.id).join(", "),
corporate, corporate,
assignment: a.name, assignment: a.name,
}; };
@@ -87,7 +93,7 @@ const MasterStatistical = (props: Props) => {
...commonData, ...commonData,
correct: 0, correct: 0,
submitted: false, submitted: false,
// date: moment(), date: null,
}; };
} }
@@ -104,6 +110,8 @@ const MasterStatistical = (props: Props) => {
[assignments, users], [assignments, users],
); );
useEffect(() => console.log(assignments), [assignments]);
const getCorporateScores = (corporateId: string): UserCount => { const getCorporateScores = (corporateId: string): UserCount => {
const corporateAssignmentsUsers = assignments.filter((a) => a.corporateId === corporateId).reduce((acc, a) => acc + a.assignees.length, 0); const corporateAssignmentsUsers = assignments.filter((a) => a.corporateId === corporateId).reduce((acc, a) => acc + a.assignees.length, 0);
@@ -145,7 +153,7 @@ const MasterStatistical = (props: Props) => {
header: "User", header: "User",
id: "user", id: "user",
cell: (info) => { cell: (info) => {
return <span>{info.getValue()}</span>; return <span>{info.getValue()?.name || "N/A"}</span>;
}, },
}), }),
columnHelper.accessor("email", { columnHelper.accessor("email", {
@@ -155,6 +163,13 @@ const MasterStatistical = (props: Props) => {
return <span>{info.getValue()}</span>; return <span>{info.getValue()}</span>;
}, },
}), }),
columnHelper.accessor("user", {
header: "Student ID",
id: "studentID",
cell: (info) => {
return <span>{(info.getValue() as StudentUser).studentID || "N/A"}</span>;
},
}),
...(displaySelection ...(displaySelection
? [ ? [
columnHelper.accessor("corporate", { columnHelper.accessor("corporate", {
@@ -166,13 +181,6 @@ const MasterStatistical = (props: Props) => {
}), }),
] ]
: []), : []),
columnHelper.accessor("corporate", {
header: "Corporate",
id: "corporate",
cell: (info) => {
return <span>{info.getValue()}</span>;
},
}),
columnHelper.accessor("assignment", { columnHelper.accessor("assignment", {
header: "Assignment", header: "Assignment",
id: "assignment", id: "assignment",
@@ -192,7 +200,7 @@ const MasterStatistical = (props: Props) => {
}, },
}), }),
columnHelper.accessor("correct", { columnHelper.accessor("correct", {
header: "Correct", header: "Score",
id: "correct", id: "correct",
cell: (info) => { cell: (info) => {
return <span>{info.getValue()}</span>; return <span>{info.getValue()}</span>;
@@ -204,7 +212,7 @@ const MasterStatistical = (props: Props) => {
cell: (info) => { cell: (info) => {
const date = info.getValue(); const date = info.getValue();
if (date) { if (date) {
return <span>{date.format("DD/MM/YYYY")}</span>; return <span>{!!date ? date.format("DD/MM/YYYY") : "N/A"}</span>;
} }
return <span>{""}</span>; return <span>{""}</span>;
@@ -214,8 +222,14 @@ const MasterStatistical = (props: Props) => {
const {rows: filteredRows, renderSearch, text: searchText} = useListSearch(searchFilters, tableResults); const {rows: filteredRows, renderSearch, text: searchText} = useListSearch(searchFilters, tableResults);
useEffect(() => setPage(0), [searchText]);
const rows = useMemo(
() => filteredRows.slice(page * SIZE, (page + 1) * SIZE > filteredRows.length ? filteredRows.length : (page + 1) * SIZE),
[filteredRows, page],
);
const table = useReactTable({ const table = useReactTable({
data: filteredRows, data: rows,
columns: defaultColumns, columns: defaultColumns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
@@ -276,6 +290,7 @@ const MasterStatistical = (props: Props) => {
<IconCard <IconCard
Icon={BsBank} Icon={BsBank}
label="Consolidate" label="Consolidate"
isLoading={isLoading}
value={getConsolidateScoreStr(consolidateScore)} value={getConsolidateScoreStr(consolidateScore)}
color="purple" color="purple"
onClick={() => { onClick={() => {
@@ -297,6 +312,7 @@ const MasterStatistical = (props: Props) => {
<IconCard <IconCard
key={corporateName} key={corporateName}
Icon={BsBank} Icon={BsBank}
isLoading={isLoading}
label={corporateName} label={corporateName}
value={value} value={value}
color="purple" color="purple"
@@ -338,13 +354,28 @@ const MasterStatistical = (props: Props) => {
</div> </div>
{renderSearch()} {renderSearch()}
<div className="flex flex-col gap-3 justify-end"> <div className="flex flex-col gap-3 justify-end">
<Button className="max-w-[200px] h-[70px]" variant="outline" onClick={triggerDownload}> <Button className="w-[200px] h-[70px]" variant="outline" isLoading={downloading} onClick={triggerDownload}>
Download Download
</Button> </Button>
</div> </div>
</div> </div>
<div> <div className="w-full h-full flex flex-col gap-4">
<div className="w-full flex gap-2 justify-between">
<Button className="w-full max-w-[200px]" disabled={page === 0} onClick={() => setPage((prev) => prev - 1)}>
Previous Page
</Button>
<div className="flex items-center gap-4 w-fit">
<span className="opacity-80">
{page * SIZE + 1} - {(page + 1) * SIZE > filteredRows.length ? filteredRows.length : (page + 1) * SIZE} /{" "}
{filteredRows.length}
</span>
<Button className="w-[200px]" disabled={(page + 1) * SIZE >= filteredRows.length} onClick={() => setPage((prev) => prev + 1)}>
Next Page
</Button>
</div>
</div>
<table className="rounded-xl h-full bg-mti-purple-ultralight/40 w-full"> <table className="rounded-xl h-full bg-mti-purple-ultralight/40 w-full">
<thead> <thead>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (

View File

@@ -83,18 +83,6 @@ export default function MasterCorporateDashboard({user}: Props) {
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id}); const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]); const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]);
const assignmentsUsers = useMemo(
() =>
[...students, ...teachers].filter((x) =>
!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id),
),
[groups, selectedUser, teachers, students],
);
const appendUserFilters = useFilterStore((state) => state.appendUserFilter); const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter(); const router = useRouter();
@@ -131,7 +119,6 @@ export default function MasterCorporateDashboard({user}: Props) {
const {users} = useUsers(); const {users} = useUsers();
const groupedByNameCorporates = useMemo( const groupedByNameCorporates = useMemo(
() => () =>
groupBy( groupBy(
@@ -374,12 +361,18 @@ export default function MasterCorporateDashboard({user}: Props) {
color="purple" color="purple"
onClick={() => router.push("/#corporate")} onClick={() => router.push("/#corporate")}
/> />
<IconCard Icon={BsBank} label="Corporate" value={groupedByNameCorporatesKeys.length} isLoading={isCorporatesLoading} color="purple" /> <IconCard
Icon={BsBank}
label="Corporate"
value={groupedByNameCorporatesKeys.length}
isLoading={isCorporatesLoading}
color="purple"
/>
<IconCard <IconCard
Icon={BsPersonFillGear} Icon={BsPersonFillGear}
isLoading={isStudentsLoading} isLoading={isStudentsLoading}
label="Student Performance" label="Student Performance"
value={students.length} value={totalStudents}
color="purple" color="purple"
onClick={() => router.push("/#studentsPerformance")} onClick={() => router.push("/#studentsPerformance")}
/> />

View File

@@ -12,7 +12,7 @@ import {calculateAverageLevel} from "@/utils/score";
import {sortByModuleName} from "@/utils/moduleUtils"; import {sortByModuleName} from "@/utils/moduleUtils";
import {capitalize} from "lodash"; import {capitalize} from "lodash";
import ProfileSummary from "@/components/ProfileSummary"; import ProfileSummary from "@/components/ProfileSummary";
import {Variant} from "@/interfaces/exam"; import {ShuffleMap, Shuffles, Variant} from "@/interfaces/exam";
import useSessions, {Session} from "@/hooks/useSessions"; import useSessions, {Session} from "@/hooks/useSessions";
import SessionCard from "@/components/Medium/SessionCard"; import SessionCard from "@/components/Medium/SessionCard";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
@@ -41,6 +41,7 @@ export default function Selection({user, page, onStart, disableSelection = false
}; };
const loadSession = async (session: Session) => { const loadSession = async (session: Session) => {
state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []})));
state.setSelectedModules(session.selectedModules); state.setSelectedModules(session.selectedModules);
state.setExam(session.exam); state.setExam(session.exam);
state.setExams(session.exams); state.setExams(session.exams);

View File

@@ -642,12 +642,12 @@ export default function UserList({
</Button> </Button>
<div className="flex items-center gap-4 w-fit"> <div className="flex items-center gap-4 w-fit">
<span className="opacity-80"> <span className="opacity-80">
{page * 16 + 1} - {(page + 1) * 16 > total ? total : (page + 1) * 16} / {total} {page * userHash.size + 1} - {(page + 1) * userHash.size > total ? total : (page + 1) * userHash.size} / {total}
</span> </span>
<Button <Button
isLoading={isLoading} isLoading={isLoading}
className="w-[200px]" className="w-[200px]"
disabled={page * 16 >= total} disabled={(page + 1) * userHash.size >= total}
onClick={() => setPage((prev) => prev + 1)}> onClick={() => setPage((prev) => prev + 1)}>
Next Page Next Page
</Button> </Button>

View File

@@ -1,254 +1,234 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import { storage } from "@/firebase"; import {storage} from "@/firebase";
import { withIronSessionApiRoute } from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import { ref, uploadBytes, getDownloadURL } from "firebase/storage"; import {ref, uploadBytes, getDownloadURL} from "firebase/storage";
import { AssignmentWithCorporateId } from "@/interfaces/results"; import {AssignmentWithCorporateId} from "@/interfaces/results";
import moment from "moment-timezone"; import moment from "moment-timezone";
import ExcelJS from "exceljs"; import ExcelJS from "exceljs";
import { getSpecificUsers } from "@/utils/users.be"; import {getSpecificUsers} from "@/utils/users.be";
import { checkAccess } from "@/utils/permissions"; import {checkAccess} from "@/utils/permissions";
import { getAssignmentsForCorporates } from "@/utils/assignments.be"; import {getAssignmentsForCorporates} from "@/utils/assignments.be";
import { search } from "@/utils/search"; import {search} from "@/utils/search";
import { getGradingSystem } from "@/utils/grading.be"; import {getGradingSystem} from "@/utils/grading.be";
import { User } from "@/interfaces/user"; import {StudentUser, User} from "@/interfaces/user";
import { calculateBandScore, getGradingLabel } from "@/utils/score"; import {calculateBandScore, getGradingLabel} from "@/utils/score";
import { Module } from "@/interfaces"; import {Module} from "@/interfaces";
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
interface TableData { interface TableData {
user: string; user: string;
email: string; studentID: string;
correct: number; email: string;
corporate: string; correct: number;
submitted: boolean; corporate: string;
date: moment.Moment; submitted: boolean;
assignment: string; date: moment.Moment;
corporateId: string; assignment: string;
score: number; corporateId: string;
level: string; score: number;
part1?: string; level: string;
part2?: string; part1?: string;
part3?: string; part2?: string;
part4?: string; part3?: string;
part5?: string; part4?: string;
part5?: string;
} }
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
// if (req.method === "GET") return get(req, res); // if (req.method === "GET") return get(req, res);
if (req.method === "POST") return await post(req, res); if (req.method === "POST") return await post(req, res);
} }
const searchFilters = [["email"], ["user"], ["userId"]]; const searchFilters = [["email"], ["user"], ["userId"]];
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {
// verify if it's a logged user that is trying to export // verify if it's a logged user that is trying to export
if (req.session.user) { if (req.session.user) {
if ( if (!checkAccess(req.session.user, ["mastercorporate", "corporate", "developer", "admin"])) {
!checkAccess(req.session.user, ["mastercorporate", "corporate", "developer", "admin"]) return res.status(403).json({error: "Unauthorized"});
) { }
return res.status(403).json({ error: "Unauthorized" }); const {
} ids,
const { startDate,
ids, endDate,
startDate, searchText,
endDate, displaySelection = true,
searchText, } = req.body as {
displaySelection = true, ids: string[];
} = req.body as { startDate?: string;
ids: string[]; endDate?: string;
startDate?: string; searchText: string;
endDate?: string; displaySelection?: boolean;
searchText: string; };
displaySelection?: boolean; const startDateParsed = startDate ? new Date(startDate) : undefined;
}; const endDateParsed = endDate ? new Date(endDate) : undefined;
const startDateParsed = startDate ? new Date(startDate) : undefined; const assignments = await getAssignmentsForCorporates(ids, startDateParsed, endDateParsed);
const endDateParsed = endDate ? new Date(endDate) : undefined;
const assignments = await getAssignmentsForCorporates(
ids,
startDateParsed,
endDateParsed
);
const assignmentUsers = [ const assignmentUsers = [...new Set(assignments.flatMap((a) => a.assignees))];
...new Set(assignments.flatMap((a) => a.assignees)), const assigners = [...new Set(assignments.map((a) => a.assigner))];
]; const users = await getSpecificUsers(assignmentUsers);
const assigners = [...new Set(assignments.map((a) => a.assigner))]; const assignerUsers = await getSpecificUsers(assigners);
const users = await getSpecificUsers(assignmentUsers);
const assignerUsers = await getSpecificUsers(assigners);
const assignerUsersGradingSystems = await Promise.all( const assignerUsersGradingSystems = await Promise.all(
assignerUsers.map(async (user: User) => { assignerUsers.map(async (user: User) => {
const data = await getGradingSystem(user); const data = await getGradingSystem(user);
// in this context I need to override as I'll have to match to the assigner // in this context I need to override as I'll have to match to the assigner
return { ...data, user: user.id }; return {...data, user: user.id};
}) }),
); );
const getGradingSystemHelper = ( const getGradingSystemHelper = (
exams: { id: string; module: Module; assignee: string }[], exams: {id: string; module: Module; assignee: string}[],
assigner: string, assigner: string,
user: User, user: User,
correct: number, correct: number,
total: number total: number,
) => { ) => {
if (exams.some((e) => e.module === "level")) { if (exams.some((e) => e.module === "level")) {
const gradingSystem = assignerUsersGradingSystems.find( const gradingSystem = assignerUsersGradingSystems.find((gs) => gs.user === assigner);
(gs) => gs.user === assigner if (gradingSystem) {
); const bandScore = calculateBandScore(correct, total, "level", user?.focus || "academic");
if (gradingSystem) { return {
const bandScore = calculateBandScore( label: getGradingLabel(bandScore, gradingSystem?.steps || []),
correct, score: bandScore,
total, };
"level", }
user.focus }
);
return {
label: getGradingLabel(bandScore, gradingSystem?.steps || []),
score: bandScore,
};
}
}
return { score: -1, label: "N/A" }; return {score: -1, label: "N/A"};
}; };
const tableResults = assignments const tableResults = assignments
.reduce((accmA: TableData[], a: AssignmentWithCorporateId) => { .reduce((accmA: TableData[], a: AssignmentWithCorporateId) => {
const userResults = a.assignees.map((assignee) => { const userResults = a.assignees.map((assignee) => {
const userStats = const userStats = a.results.find((r) => r.user === assignee)?.stats || [];
a.results.find((r) => r.user === assignee)?.stats || []; const userData = users.find((u) => u.id === assignee);
const userData = users.find((u) => u.id === assignee); const corporateUser = users.find((u) => u.id === a.assigner);
const corporateUser = users.find((u) => u.id === a.assigner); const correct = userStats.reduce((n, e) => n + e.score.correct, 0);
const correct = userStats.reduce((n, e) => n + e.score.correct, 0); const total = userStats.reduce((n, e) => n + e.score.total, 0);
const total = userStats.reduce((n, e) => n + e.score.total, 0); const {label: level, score} = getGradingSystemHelper(a.exams, a.assigner, userData!, correct, total);
const { label: level, score } = getGradingSystemHelper(
a.exams,
a.assigner,
userData!,
correct,
total
);
console.log("Level", level); const commonData = {
const commonData = { user: userData?.name || "",
user: userData?.name || "", email: userData?.email || "",
email: userData?.email || "", studentID: (userData as StudentUser).studentID || "",
userId: assignee, userId: assignee,
corporateId: a.corporateId, corporateId: a.corporateId,
corporate: corporateUser?.name || "", corporate: corporateUser?.name || "",
assignment: a.name, assignment: a.name,
level, level,
score, score,
}; };
if (userStats.length === 0) { if (userStats.length === 0) {
return { return {
...commonData, ...commonData,
correct: 0, correct: 0,
submitted: false, submitted: false,
// date: moment(), // date: moment(),
}; };
} }
const partsData = userStats.every((e) => e.module === "level") const partsData = userStats.every((e) => e.module === "level")
? userStats.reduce((acc, e, index) => { ? userStats.reduce((acc, e, index) => {
return { return {
...acc, ...acc,
[`part${index}`]: `${e.score.correct}/${e.score.total}`, [`part${index}`]: `${e.score.correct}/${e.score.total}`,
}; };
}, {}) }, {})
: {}; : {};
return { return {
...commonData, ...commonData,
correct, correct,
submitted: true, submitted: true,
date: moment.max(userStats.map((e) => moment(e.date))), date: moment.max(userStats.map((e) => moment(e.date))),
...partsData, ...partsData,
}; };
}) as TableData[]; }) as TableData[];
return [...accmA, ...userResults]; return [...accmA, ...userResults];
}, []) }, [])
.sort((a, b) => b.score - a.score); .sort((a, b) => b.score - a.score);
// Create a new workbook and add a worksheet // Create a new workbook and add a worksheet
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("Master Statistical"); const worksheet = workbook.addWorksheet("Master Statistical");
const headers = [ const headers = [
{ {
label: "User", label: "User",
value: (entry: TableData) => entry.user, value: (entry: TableData) => entry.user,
}, },
{ {
label: "Email", label: "Email",
value: (entry: TableData) => entry.email, value: (entry: TableData) => entry.email,
}, },
...(displaySelection {
? [ label: "Student ID",
{ value: (entry: TableData) => entry.studentID,
label: "Corporate", },
value: (entry: TableData) => entry.corporate, ...(displaySelection
}, ? [
] {
: []), label: "Corporate",
{ value: (entry: TableData) => entry.corporate,
label: "Assignment", },
value: (entry: TableData) => entry.assignment, ]
}, : []),
{ {
label: "Submitted", label: "Assignment",
value: (entry: TableData) => (entry.submitted ? "Yes" : "No"), value: (entry: TableData) => entry.assignment,
}, },
{ {
label: "Correct", label: "Submitted",
value: (entry: TableData) => entry.correct, value: (entry: TableData) => (entry.submitted ? "Yes" : "No"),
}, },
{ {
label: "Date", label: "Correct",
value: (entry: TableData) => entry.date?.format("YYYY/MM/DD") || "", value: (entry: TableData) => entry.correct,
}, },
{ {
label: "Level", label: "Date",
value: (entry: TableData) => entry.level, value: (entry: TableData) => entry.date?.format("YYYY/MM/DD") || "",
}, },
...new Array(5).fill(0).map((_, index) => ({ {
label: `Part ${index + 1}`, label: "Level",
value: (entry: TableData) => { value: (entry: TableData) => entry.level,
const key = `part${index}` as keyof TableData; },
return entry[key] || ""; ...new Array(5).fill(0).map((_, index) => ({
}, label: `Part ${index + 1}`,
})), value: (entry: TableData) => {
]; const key = `part${index}` as keyof TableData;
return entry[key] || "";
},
})),
];
const filteredSearch = searchText const filteredSearch = searchText ? search(searchText, searchFilters, tableResults) : tableResults;
? search(searchText, searchFilters, tableResults)
: tableResults;
worksheet.addRow(headers.map((h) => h.label)); worksheet.addRow(headers.map((h) => h.label));
(filteredSearch as TableData[]).forEach((entry) => { (filteredSearch as TableData[]).forEach((entry) => {
worksheet.addRow(headers.map((h) => h.value(entry))); worksheet.addRow(headers.map((h) => h.value(entry)));
}); });
// Convert workbook to Buffer (Node.js) or Blob (Browser) // Convert workbook to Buffer (Node.js) or Blob (Browser)
const buffer = await workbook.xlsx.writeBuffer(); const buffer = await workbook.xlsx.writeBuffer();
// generate the file ref for storage // generate the file ref for storage
const fileName = `${Date.now().toString()}.xlsx`; const fileName = `${Date.now().toString()}.xlsx`;
const refName = `statistical/${fileName}`; const refName = `statistical/${fileName}`;
const fileRef = ref(storage, refName); const fileRef = ref(storage, refName);
// upload the pdf to storage // upload the pdf to storage
await uploadBytes(fileRef, buffer, { await uploadBytes(fileRef, buffer, {
contentType: contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", });
});
const url = await getDownloadURL(fileRef); const url = await getDownloadURL(fileRef);
res.status(200).end(url); res.status(200).end(url);
return; return;
} }
return res.status(401).json({ error: "Unauthorized" }); return res.status(401).json({error: "Unauthorized"});
} }

View File

@@ -1,109 +1,91 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import { withIronSessionApiRoute } from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import { Group } from "@/interfaces/user"; import {Group} from "@/interfaces/user";
import { updateExpiryDateOnGroup } from "@/utils/groups.be"; import {updateExpiryDateOnGroup} from "@/utils/groups.be";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return await get(req, res); if (req.method === "GET") return await get(req, res);
if (req.method === "DELETE") return await del(req, res); if (req.method === "DELETE") return await del(req, res);
if (req.method === "PATCH") return await patch(req, res); if (req.method === "PATCH") return await patch(req, res);
res.status(404).json(undefined); res.status(404).json(undefined);
} }
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ ok: false }); res.status(401).json({ok: false});
return; return;
} }
const { id } = req.query as { id: string }; const {id} = req.query as {id: string};
const snapshot = await db.collection("groups").findOne({ id: id}); const snapshot = await db.collection("groups").findOne({id: id});
if (snapshot) { if (snapshot) {
res.status(200).json({ ...snapshot }); res.status(200).json({...snapshot});
} else { } else {
res.status(404).json(undefined); res.status(404).json(undefined);
} }
} }
async function del(req: NextApiRequest, res: NextApiResponse) { async function del(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ ok: false }); res.status(401).json({ok: false});
return; return;
} }
const { id } = req.query as { id: string }; const {id} = req.query as {id: string};
const group = await db.collection("groups").findOne<Group>({id: id}); const group = await db.collection("groups").findOne<Group>({id: id});
if (!group) { if (!group) {
res.status(404); res.status(404);
return; return;
} }
const user = req.session.user; const user = req.session.user;
if ( if (user.type === "admin" || user.type === "developer" || user.id === group.admin) {
user.type === "admin" || await db.collection("groups").deleteOne({id: id});
user.type === "developer" ||
user.id === group.admin
) {
await db.collection("groups").deleteOne({ id: id });
res.status(200).json({ ok: true }); res.status(200).json({ok: true});
return; return;
} }
res.status(403).json({ ok: false }); res.status(403).json({ok: false});
} }
async function patch(req: NextApiRequest, res: NextApiResponse) { async function patch(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ ok: false }); res.status(401).json({ok: false});
return; return;
} }
const { id } = req.query as { id: string }; const {id} = req.query as {id: string};
const group = await db.collection("groups").findOne<Group>({id: id}); const group = await db.collection("groups").findOne<Group>({id: id});
if (!group) { if (!group) {
res.status(404); res.status(404);
return; return;
} }
const user = req.session.user; const user = req.session.user;
if ( if (user.type === "admin" || user.type === "developer" || user.id === group.admin) {
user.type === "admin" || if ("participants" in req.body) {
user.type === "developer" || const newParticipants = (req.body.participants as string[]).filter((x) => !group.participants.includes(x));
user.id === group.admin await Promise.all(newParticipants.map(async (p) => await updateExpiryDateOnGroup(p, group.admin)));
) { }
if ("participants" in req.body) {
const newParticipants = (req.body.participants as string[]).filter(
(x) => !group.participants.includes(x),
);
await Promise.all(
newParticipants.map(
async (p) => await updateExpiryDateOnGroup(p, group.admin),
),
);
}
await db.collection("grading").updateOne( await db.collection("groups").updateOne({id: req.session.user.id}, {$set: {id, ...req.body}}, {upsert: true});
{ id: req.session.user.id },
{ $set: {id: id, ...req.body} },
{ upsert: true }
);
res.status(200).json({ ok: true }); res.status(200).json({ok: true});
return; return;
} }
res.status(403).json({ ok: false }); res.status(403).json({ok: false});
} }

View File

@@ -1,18 +1,18 @@
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import { Assignment } from "@/interfaces/results"; import {Assignment} from "@/interfaces/results";
import { getAllAssignersByCorporate } from "@/utils/groups.be"; import {getAllAssignersByCorporate} from "@/utils/groups.be";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
export const getAssignmentsByAssigner = async (id: string, startDate?: Date, endDate?: Date) => { export const getAssignmentsByAssigner = async (id: string, startDate?: Date, endDate?: Date) => {
let query: any = { assigner: id }; let query: any = {assigner: id};
if (startDate) { if (startDate) {
query.startDate = { $gte: startDate.toISOString() }; query.startDate = {$gte: startDate.toISOString()};
} }
if (endDate) { if (endDate) {
query.endDate = { $lte: endDate.toISOString() }; query.endDate = {$lte: endDate.toISOString()};
} }
return await db.collection("assignments").find<Assignment>(query).toArray(); return await db.collection("assignments").find<Assignment>(query).toArray();
@@ -26,7 +26,14 @@ export const getAssignmentsByAssignerBetweenDates = async (id: string, startDate
}; };
export const getAssignmentsByAssigners = async (ids: string[], startDate?: Date, endDate?: Date) => { export const getAssignmentsByAssigners = async (ids: string[], startDate?: Date, endDate?: Date) => {
return (await Promise.all(ids.map((id) => getAssignmentsByAssigner(id, startDate, endDate)))).flat(); return await db
.collection("assignments")
.find<Assignment>({
assigner: {$in: ids},
...(!!startDate ? {startDate: {$gte: startDate.toISOString()}} : {}),
...(!!endDate ? {endDate: {$lte: endDate.toISOString()}} : {}),
})
.toArray();
}; };
export const getAssignmentsForCorporates = async (idsList: string[], startDate?: Date, endDate?: Date) => { export const getAssignmentsForCorporates = async (idsList: string[], startDate?: Date, endDate?: Date) => {
@@ -57,4 +64,4 @@ export const getAssignmentsForCorporates = async (idsList: string[], startDate?:
); );
return assignments.flat(); return assignments.flat();
} };

View File

@@ -1,8 +1,9 @@
import {app} from "@/firebase"; import {app} from "@/firebase";
import {Assignment} from "@/interfaces/results";
import {CorporateUser, Group, MasterCorporateUser, StudentUser, TeacherUser, User} from "@/interfaces/user"; import {CorporateUser, Group, MasterCorporateUser, StudentUser, TeacherUser, User} from "@/interfaces/user";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import moment from "moment"; import moment from "moment";
import {getUser} from "./users.be"; import {getLinkedUsers, getUser} from "./users.be";
import {getSpecificUsers} from "./users.be"; import {getSpecificUsers} from "./users.be";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
@@ -71,17 +72,10 @@ export const getUsersGroups = async (ids: string[]) => {
}; };
export const getAllAssignersByCorporate = async (corporateID: string): Promise<string[]> => { export const getAllAssignersByCorporate = async (corporateID: string): Promise<string[]> => {
const groups = await getUserGroups(corporateID); const linkedTeachers = await getLinkedUsers(corporateID, "mastercorporate", "teacher");
const groupUsers = (await Promise.all(groups.map(async (g) => await Promise.all(g.participants.map(getUser))))) const linkedCorporates = await getLinkedUsers(corporateID, "mastercorporate", "corporate");
.flat()
.filter((x) => !!x) as User[];
const teacherPromises = await Promise.all(
groupUsers.map(async (u) =>
u.type === "teacher" ? u.id : u.type === "corporate" ? [...(await getAllAssignersByCorporate(u.id)), u.id] : undefined,
),
);
return teacherPromises.filter((x) => !!x).flat() as string[]; return [...linkedTeachers.users.map((x) => x.id), ...linkedCorporates.users.map((x) => x.id)];
}; };
export const getGroupsForUser = async (admin?: string, participant?: string) => { export const getGroupsForUser = async (admin?: string, participant?: string) => {