Refactor most getServerProps to fetch independent request in parallel and projected the data only to return the necessary fields and changed some functions

This commit is contained in:
José Marques Lima
2025-01-30 18:25:42 +00:00
parent 58aebaa66c
commit 98ba0bfc04
36 changed files with 5796 additions and 4058 deletions

View File

@@ -7,14 +7,14 @@ import { Session } from "@/hooks/useSessions";
import { Entity, EntityWithRoles } from "@/interfaces/entity";
import { Exam } from "@/interfaces/exam";
import { Assignment, AssignmentResult } from "@/interfaces/results";
import { StudentUser, User } from "@/interfaces/user";
import { StudentUser, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import { filterBy, findBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { getEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getExamsByIds } from "@/utils/exams.be";
import { findAllowedEntities } from "@/utils/permissions";
import { findAllowedEntities } from "@/utils/permissions";
import { getSessionsByAssignments } from "@/utils/sessions.be";
import { isAdmin } from "@/utils/users";
import { getEntitiesUsers } from "@/utils/users.be";
@@ -28,278 +28,399 @@ import Head from "next/head";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import ReactDatePicker from "react-datepicker";
import {
BsBank,
BsChevronLeft,
BsX,
} from "react-icons/bs";
import { BsBank, BsChevronLeft, BsX } from "react-icons/bs";
interface Props {
user: User;
students: StudentUser[];
entities: EntityWithRoles[];
assignments: Assignment[];
sessions: Session[]
exams: Exam[]
user: User;
students: StudentUser[];
entities: EntityWithRoles[];
assignments: Assignment[];
sessions: Session[];
exams: Exam[];
}
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
const user = await requestUser(req, res);
if (!user) return redirect("/login");
const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
const allowedEntities = findAllowedEntities(user, entities, 'view_entity_statistics')
const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(
isAdmin(user) ? undefined : entityIDS
);
const allowedEntities = findAllowedEntities(
user,
entities,
"view_entity_statistics"
);
if (allowedEntities.length === 0) return redirect("/")
if (allowedEntities.length === 0) return redirect("/");
const studentsAllowedEntities = findAllowedEntities(user, entities, 'view_students')
const students = await getEntitiesUsers(mapBy(studentsAllowedEntities, 'id'), { type: "student" })
const studentsAllowedEntities = findAllowedEntities(
user,
entities,
"view_students"
);
const [students, assignments] = await Promise.all([
getEntitiesUsers(mapBy(studentsAllowedEntities, "id"), { type: "student" }),
getEntitiesAssignments(mapBy(entities, "id")),
]);
const [sessions, exams] = await Promise.all([
getSessionsByAssignments(mapBy(assignments, "id")),
getExamsByIds(assignments.flatMap((a) => a.exams)),
]);
const assignments = await getEntitiesAssignments(mapBy(entities, "id"));
const sessions = await getSessionsByAssignments(mapBy(assignments, 'id'))
const exams = await getExamsByIds(assignments.flatMap(a => a.exams))
return { props: serialize({ user, students, entities: allowedEntities, assignments, sessions, exams }) };
return {
props: serialize({
user,
students,
entities: allowedEntities,
assignments,
sessions,
exams,
}),
};
}, sessionOptions);
interface Item {
student: StudentUser
result?: AssignmentResult
assignment: Assignment
exams: Exam[]
entity: Entity
session?: Session
student: StudentUser;
result?: AssignmentResult;
assignment: Assignment;
exams: Exam[];
entity: Entity;
session?: Session;
}
const columnHelper = createColumnHelper<Item>();
export default function Statistical({ user, students, entities, assignments, sessions, exams }: Props) {
const [startDate, setStartDate] = useState<Date>(new Date());
const [endDate, setEndDate] = useState<Date | null>(moment().add(1, 'month').toDate());
const [selectedEntities, setSelectedEntities] = useState<string[]>([])
const [isDownloading, setIsDownloading] = useState(false)
export default function Statistical({
user,
students,
entities,
assignments,
sessions,
exams,
}: Props) {
const [startDate, setStartDate] = useState<Date>(new Date());
const [endDate, setEndDate] = useState<Date | null>(
moment().add(1, "month").toDate()
);
const [selectedEntities, setSelectedEntities] = useState<string[]>([]);
const [isDownloading, setIsDownloading] = useState(false);
const entitiesAllowDownload = useAllowedEntities(user, entities, 'download_statistics_report')
const entitiesAllowDownload = useAllowedEntities(
user,
entities,
"download_statistics_report"
);
const resetDateRange = () => {
const orderedAssignments = orderBy(assignments, ['startDate'], ['asc'])
setStartDate(moment(orderedAssignments.shift()?.startDate || "2024-01-01T00:00:01.986Z").toDate())
setEndDate(moment().add(1, 'month').toDate())
}
const resetDateRange = () => {
const orderedAssignments = orderBy(assignments, ["startDate"], ["asc"]);
setStartDate(
moment(
orderedAssignments.shift()?.startDate || "2024-01-01T00:00:01.986Z"
).toDate()
);
setEndDate(moment().add(1, "month").toDate());
};
useEffect(resetDateRange, [assignments])
useEffect(resetDateRange, [assignments]);
const updateDateRange = (dates: [Date, Date | null]) => {
const [start, end] = dates;
setStartDate(start!);
setEndDate(end);
};
const updateDateRange = (dates: [Date, Date | null]) => {
const [start, end] = dates;
setStartDate(start!);
setEndDate(end);
};
const toggleEntity = (id: string) => setSelectedEntities(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
const toggleEntity = (id: string) =>
setSelectedEntities((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
const renderAssignmentResolution = (entityID: string) => {
const entityAssignments = filterBy(assignments, 'entity', entityID)
const total = entityAssignments.reduce((acc, curr) => acc + curr.assignees.length, 0)
const results = entityAssignments.reduce((acc, curr) => acc + curr.results.length, 0)
const renderAssignmentResolution = (entityID: string) => {
const entityAssignments = filterBy(assignments, "entity", entityID);
const total = entityAssignments.reduce(
(acc, curr) => acc + curr.assignees.length,
0
);
const results = entityAssignments.reduce(
(acc, curr) => acc + curr.results.length,
0
);
return `${results}/${total}`
}
return `${results}/${total}`;
};
const totalAssignmentResolution = useMemo(() => {
const total = assignments.reduce((acc, curr) => acc + curr.assignees.length, 0)
const results = assignments.reduce((acc, curr) => acc + curr.results.length, 0)
const totalAssignmentResolution = useMemo(() => {
const total = assignments.reduce(
(acc, curr) => acc + curr.assignees.length,
0
);
const results = assignments.reduce(
(acc, curr) => acc + curr.results.length,
0
);
return { results, total }
}, [assignments])
return { results, total };
}, [assignments]);
const filteredAssignments = useMemo(() => {
if (!startDate && !endDate) return assignments
const startDateFiltered = startDate ? assignments.filter(a => moment(a.startDate).isSameOrAfter(moment(startDate))) : assignments
return endDate ? startDateFiltered.filter(a => moment(a.endDate).isSameOrBefore(moment(endDate))) : startDateFiltered
}, [startDate, endDate, assignments])
const filteredAssignments = useMemo(() => {
if (!startDate && !endDate) return assignments;
const startDateFiltered = startDate
? assignments.filter((a) =>
moment(a.startDate).isSameOrAfter(moment(startDate))
)
: assignments;
return endDate
? startDateFiltered.filter((a) =>
moment(a.endDate).isSameOrBefore(moment(endDate))
)
: startDateFiltered;
}, [startDate, endDate, assignments]);
const data: Item[] = useMemo(() =>
filteredAssignments.filter(a => selectedEntities.includes(a.entity || "")).flatMap(a => a.assignees.map(x => {
const result = findBy(a.results, 'user', x)
const student = findBy(students, 'id', x)
const entity = findBy(entities, 'id', a.entity)
const assignmentExams = exams.filter(e => a.exams.map(x => `${x.id}_${x.module}`).includes(`${e.id}_${e.module}`))
const session = sessions.find(s => s.assignment?.id === a.id && s.user === x)
const data: Item[] = useMemo(
() =>
filteredAssignments
.filter((a) => selectedEntities.includes(a.entity || ""))
.flatMap((a) =>
a.assignees.map((x) => {
const result = findBy(a.results, "user", x);
const student = findBy(students, "id", x);
const entity = findBy(entities, "id", a.entity);
const assignmentExams = exams.filter((e) =>
a.exams
.map((x) => `${x.id}_${x.module}`)
.includes(`${e.id}_${e.module}`)
);
const session = sessions.find(
(s) => s.assignment?.id === a.id && s.user === x
);
if (!student) return undefined
return { student, result, assignment: a, exams: assignmentExams, session, entity }
})).filter(x => !!x) as Item[],
[students, selectedEntities, filteredAssignments, exams, sessions, entities]
)
if (!student) return undefined;
return {
student,
result,
assignment: a,
exams: assignmentExams,
session,
entity,
};
})
)
.filter((x) => !!x) as Item[],
[students, selectedEntities, filteredAssignments, exams, sessions, entities]
);
const sortedData: Item[] = useMemo(() => data.sort((a, b) => {
const aTotalScore = a.result?.stats.filter(x => !x.isPractice).reduce((acc, curr) => acc + curr.score.correct, 0) || 0
const bTotalScore = b.result?.stats.filter(x => !x.isPractice).reduce((acc, curr) => acc + curr.score.correct, 0) || 0
const sortedData: Item[] = useMemo(
() =>
data.sort((a, b) => {
const aTotalScore =
a.result?.stats
.filter((x) => !x.isPractice)
.reduce((acc, curr) => acc + curr.score.correct, 0) || 0;
const bTotalScore =
b.result?.stats
.filter((x) => !x.isPractice)
.reduce((acc, curr) => acc + curr.score.correct, 0) || 0;
return bTotalScore - aTotalScore
}), [data])
return bTotalScore - aTotalScore;
}),
[data]
);
const downloadExcel = async () => {
setIsDownloading(true)
const downloadExcel = async () => {
setIsDownloading(true);
const request = await axios.post("/api/statistical", {
entities: entities.filter(e => selectedEntities.includes(e.id)),
items: data,
assignments: filteredAssignments,
startDate,
endDate
}, {
responseType: 'blob'
})
const request = await axios.post(
"/api/statistical",
{
entities: entities.filter((e) => selectedEntities.includes(e.id)),
items: data,
assignments: filteredAssignments,
startDate,
endDate,
},
{
responseType: "blob",
}
);
const href = URL.createObjectURL(request.data)
const link = document.createElement('a');
link.href = href;
link.setAttribute('download', `statistical_${new Date().toISOString()}.xlsx`);
document.body.appendChild(link);
link.click();
const href = URL.createObjectURL(request.data);
const link = document.createElement("a");
link.href = href;
link.setAttribute(
"download",
`statistical_${new Date().toISOString()}.xlsx`
);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(href);
document.body.removeChild(link);
URL.revokeObjectURL(href);
setIsDownloading(false)
}
setIsDownloading(false);
};
const columns = [
columnHelper.accessor("student.name", {
header: "Student",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("student.studentID", {
header: "Student ID",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("student.email", {
header: "E-mail",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("entity.label", {
header: "Entity",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("assignment.name", {
header: "Assignment",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("assignment.startDate", {
header: "Date",
cell: (info) => moment(info.getValue()).format("DD/MM/YYYY"),
}),
columnHelper.accessor("result", {
header: "Progress",
cell: (info) => {
const student = info.row.original.student
const session = info.row.original.session
const columns = [
columnHelper.accessor("student.name", {
header: "Student",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("student.studentID", {
header: "Student ID",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("student.email", {
header: "E-mail",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("entity.label", {
header: "Entity",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("assignment.name", {
header: "Assignment",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("assignment.startDate", {
header: "Date",
cell: (info) => moment(info.getValue()).format("DD/MM/YYYY"),
}),
columnHelper.accessor("result", {
header: "Progress",
cell: (info) => {
const student = info.row.original.student;
const session = info.row.original.session;
if (!student.lastLogin) return <span className="text-mti-red-dark">Never logged in</span>
if (info.getValue()) return <span className="text-mti-green font-semibold">Submitted</span>
if (!session) return <span className="text-mti-rose">Not started</span>
if (!student.lastLogin)
return <span className="text-mti-red-dark">Never logged in</span>;
if (info.getValue())
return (
<span className="text-mti-green font-semibold">Submitted</span>
);
if (!session) return <span className="text-mti-rose">Not started</span>;
return <span className="font-semibold">
{capitalize(session.exam?.module || "")} Module, Part {session.partIndex + 1}, Exercise {session.exerciseIndex + 1}
</span>
},
})
]
return (
<span className="font-semibold">
{capitalize(session.exam?.module || "")} Module, Part{" "}
{session.partIndex + 1}, Exercise {session.exerciseIndex + 1}
</span>
);
},
}),
];
return (
<>
<Head>
<title>Statistical | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
return (
<>
<Head>
<title>Statistical | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<Link href="/dashboard" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<BsChevronLeft />
</Link>
<h2 className="font-bold text-2xl">Statistical</h2>
</div>
<Checkbox
onChange={value => setSelectedEntities(value ? mapBy(entities, 'id') : [])}
isChecked={selectedEntities.length === entities.length}
>
Select All
</Checkbox>
</div>
<Separator />
</div>
<>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<Link
href="/dashboard"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
>
<BsChevronLeft />
</Link>
<h2 className="font-bold text-2xl">Statistical</h2>
</div>
<Checkbox
onChange={(value) =>
setSelectedEntities(value ? mapBy(entities, "id") : [])
}
isChecked={selectedEntities.length === entities.length}
>
Select All
</Checkbox>
</div>
<Separator />
</div>
<section className="flex flex-col gap-3">
<div className="w-full flex items-center justify-between gap-4 flex-wrap">
{entities.map(entity => (
<button
onClick={() => toggleEntity(entity.id)}
className={clsx(
"flex flex-col items-center justify-between gap-3 border-2 drop-shadow rounded-xl bg-white p-8 px-2 w-48 h-52",
"transition ease-in-out duration-300 hover:shadow-xl hover:border-mti-purple",
selectedEntities.includes(entity.id) && "border-mti-purple text-mti-purple"
)}
key={entity.id}
>
<BsBank size={48} />
<div className="flex flex-col gap-1">
<span>{entity.label}</span>
<span className={clsx("font-semibold")}>
{renderAssignmentResolution(entity.id)}
</span>
</div>
</button>
))}
</div>
<section className="flex flex-col gap-3">
<div className="w-full flex items-center justify-between gap-4 flex-wrap">
{entities.map((entity) => (
<button
onClick={() => toggleEntity(entity.id)}
className={clsx(
"flex flex-col items-center justify-between gap-3 border-2 drop-shadow rounded-xl bg-white p-8 px-2 w-48 h-52",
"transition ease-in-out duration-300 hover:shadow-xl hover:border-mti-purple",
selectedEntities.includes(entity.id) &&
"border-mti-purple text-mti-purple"
)}
key={entity.id}
>
<BsBank size={48} />
<div className="flex flex-col gap-1">
<span>{entity.label}</span>
<span className={clsx("font-semibold")}>
{renderAssignmentResolution(entity.id)}
</span>
</div>
</button>
))}
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ReactDatePicker
className={clsx(
"p-6 px-12 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out",
)}
dateFormat="dd/MM/yyyy"
selectsRange
selected={startDate}
onChange={updateDateRange}
startDate={startDate}
endDate={endDate}
/>
{startDate !== null && endDate !== null && (
<button onClick={resetDateRange} className="transition ease-in-out duration-300 rounded-full p-2 hover:bg-mti-gray-cool/10">
<BsX size={24} />
</button>
)}
</div>
<span className="font-semibold text-lg pr-1">
Total: {totalAssignmentResolution.results} / {totalAssignmentResolution.total}
</span>
</div>
</section>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ReactDatePicker
className={clsx(
"p-6 px-12 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out"
)}
dateFormat="dd/MM/yyyy"
selectsRange
selected={startDate}
onChange={updateDateRange}
startDate={startDate}
endDate={endDate}
/>
{startDate !== null && endDate !== null && (
<button
onClick={resetDateRange}
className="transition ease-in-out duration-300 rounded-full p-2 hover:bg-mti-gray-cool/10"
>
<BsX size={24} />
</button>
)}
</div>
<span className="font-semibold text-lg pr-1">
Total: {totalAssignmentResolution.results} /{" "}
{totalAssignmentResolution.total}
</span>
</div>
</section>
{selectedEntities.length > 0 && (
<Table
columns={columns}
data={sortedData}
searchFields={[["student", "name"], ["student", "email"], ["student", "studentID"], ["exams", "id"], ["assignment", "name"]]}
searchPlaceholder="Search by student, assignment or exam..."
onDownload={entitiesAllowDownload.length > 0 ? downloadExcel : undefined}
isDownloadLoading={isDownloading}
/>
)}
</>
</>
)
{selectedEntities.length > 0 && (
<Table
columns={columns}
data={sortedData}
searchFields={[
["student", "name"],
["student", "email"],
["student", "studentID"],
["exams", "id"],
["assignment", "name"],
]}
searchPlaceholder="Search by student, assignment or exam..."
onDownload={
entitiesAllowDownload.length > 0 ? downloadExcel : undefined
}
isDownloadLoading={isDownloading}
/>
)}
</>
</>
);
}