Revamped the statistical page to work with the new entity system, along with some other improvements to it
This commit is contained in:
298
src/pages/statistical.tsx
Normal file
298
src/pages/statistical.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import Layout from "@/components/High/Layout";
|
||||
import Table from "@/components/High/Table";
|
||||
import Checkbox from "@/components/Low/Checkbox";
|
||||
import Separator from "@/components/Low/Separator";
|
||||
import { Session } from "@/hooks/useSessions";
|
||||
import { Entity, EntityWithRoles } from "@/interfaces/entity";
|
||||
import { Exam } from "@/interfaces/exam";
|
||||
import { Assignment, AssignmentResult } from "@/interfaces/results";
|
||||
import { Group, Stat, 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 { checkAccess, findAllowedEntities } from "@/utils/permissions";
|
||||
import { getSessionsByAssignments, getSessionsByUser } from "@/utils/sessions.be";
|
||||
import { getStatsByUsers } from "@/utils/stats.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")
|
||||
|
||||
if (!checkAccess(user, ["admin", "developer", "mastercorporate"]))
|
||||
return redirect("/")
|
||||
|
||||
const entityIDS = mapBy(user.entities, "id") || [];
|
||||
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
|
||||
|
||||
const studentsAllowedEntities = findAllowedEntities(user, entities, 'view_students')
|
||||
const students = await getEntitiesUsers(mapBy(studentsAllowedEntities, 'id'), { type: "student" })
|
||||
|
||||
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, assignments, sessions, exams }) };
|
||||
}, sessionOptions);
|
||||
|
||||
interface Item {
|
||||
student: StudentUser
|
||||
result?: AssignmentResult
|
||||
assignment: Assignment
|
||||
exams: Exam[]
|
||||
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 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 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 }
|
||||
})).filter(x => !!x) as Item[],
|
||||
[students, selectedEntities, filteredAssignments, exams, sessions]
|
||||
)
|
||||
|
||||
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 () => {
|
||||
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);
|
||||
}
|
||||
|
||||
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("assignment.name", {
|
||||
header: "Assignment",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
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>
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("result", {
|
||||
header: "Score",
|
||||
cell: (info) => {
|
||||
if (!info.getValue()) return null
|
||||
const correct = info.getValue()!.stats.filter(x => !x.isPractice).reduce((acc, curr) => acc + curr.score.correct, 0)
|
||||
const total = info.getValue()!.stats.filter(x => !x.isPractice).reduce((acc, curr) => acc + curr.score.total, 0)
|
||||
|
||||
return `${correct} / ${total}`
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
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>
|
||||
|
||||
<Layout user={user}>
|
||||
<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={downloadExcel}
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user