427 lines
13 KiB
TypeScript
427 lines
13 KiB
TypeScript
/* eslint-disable @next/next/no-img-element */
|
|
import Table from "@/components/High/Table";
|
|
import Checkbox from "@/components/Low/Checkbox";
|
|
import Separator from "@/components/Low/Separator";
|
|
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
|
|
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 { 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 { getSessionsByAssignments } from "@/utils/sessions.be";
|
|
import { isAdmin } from "@/utils/users";
|
|
import { getEntitiesUsers } from "@/utils/users.be";
|
|
import { createColumnHelper } from "@tanstack/react-table";
|
|
import axios from "axios";
|
|
import { clsx } from "clsx";
|
|
import { withIronSessionSsr } from "iron-session/next";
|
|
import { capitalize, orderBy } from "lodash";
|
|
import moment from "moment";
|
|
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";
|
|
|
|
interface Props {
|
|
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 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("/");
|
|
|
|
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)),
|
|
]);
|
|
|
|
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;
|
|
}
|
|
|
|
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);
|
|
|
|
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());
|
|
};
|
|
|
|
useEffect(resetDateRange, [assignments]);
|
|
|
|
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 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}`;
|
|
};
|
|
|
|
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]);
|
|
|
|
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
|
|
);
|
|
|
|
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;
|
|
|
|
return bTotalScore - aTotalScore;
|
|
}),
|
|
[data]
|
|
);
|
|
|
|
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 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);
|
|
|
|
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;
|
|
|
|
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 (
|
|
<>
|
|
<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>
|
|
|
|
<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>
|
|
|
|
{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}
|
|
/>
|
|
)}
|
|
</>
|
|
</>
|
|
);
|
|
}
|